diff --git a/.github/workflows/live-obs.yml b/.github/workflows/live-obs.yml index c5a0ef1..99773bd 100644 --- a/.github/workflows/live-obs.yml +++ b/.github/workflows/live-obs.yml @@ -3,7 +3,7 @@ # # yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json -name: Live OBS validation +name: Full suite against live OBS on: workflow_dispatch: @@ -26,7 +26,7 @@ env: jobs: validate: - name: Validate JSON and MessagePack against OBS + name: Unit and live tests against OBS runs-on: ubuntu-24.04 timeout-minutes: 25 @@ -97,44 +97,118 @@ jobs: pulseaudio --start --exit-idle-time=-1 pactl load-module module-null-sink sink_name=ci_sink - - name: Start OBS + - name: Build the tests + run: dotnet build ${{ github.workspace }}/ObsWebSocket.Tests/ObsWebSocket.Tests.csproj --configuration Release + + - name: Run the full suite against OBS + # No filter: the unit tests and the live sweep in one run, so the coverage report covers + # everything at once. Each framework gets an OBS of its own: the write sweep resets video, + # and enumerating outputs after that reads a freed encoder and takes OBS down (#25), so a + # second framework sharing the first one's OBS would start against a poisoned instance. env: DISPLAY: ":99" run: | - set -euo pipefail - obs --minimize-to-tray --disable-updater --disable-shutdown-check \ - > obs-stdout.log 2>&1 & - echo $! > obs.pid - - # Waited for rather than slept: pipeline init time varies with the runner. - for _ in $(seq 1 90); do - if nc -z localhost "${OBS_WEBSOCKET_PORT}"; then - echo "obs-websocket is listening." - exit 0 + set -uo pipefail + + start_obs() { + obs --minimize-to-tray --disable-updater --disable-shutdown-check \ + > "obs-stdout-$1.log" 2>&1 & + echo $! > obs.pid + # Waited for rather than slept: pipeline init time varies with the runner. + for _ in $(seq 1 90); do + if nc -z localhost "${OBS_WEBSOCKET_PORT}"; then + return 0 + fi + sleep 2 + done + echo "::error::obs-websocket never started listening for $1." + cat "obs-stdout-$1.log" + return 1 + } + + stop_obs() { + # Terminated, not killed, so OBS clears its unclean-shutdown marker. + pid="$(cat obs.pid)" + kill "$pid" 2>/dev/null || true + for _ in $(seq 1 30); do + kill -0 "$pid" 2>/dev/null || return 0 + sleep 1 + done + kill -9 "$pid" 2>/dev/null || true + } + + status=0 + for framework in net9.0 net10.0 net11.0; do + echo "::group::$framework" + if start_obs "$framework"; then + dotnet test --project ${{ github.workspace }}/ObsWebSocket.Tests/ObsWebSocket.Tests.csproj \ + --configuration Release --no-build --framework "$framework" \ + --results-directory-layout per-module \ + -- --test-parameter Obs.ServerUri=ws://localhost:${{ env.OBS_WEBSOCKET_PORT }} \ + --test-parameter Obs.Password=${{ env.OBS_WEBSOCKET_PASSWORD }} \ + --test-parameter Obs.Required=true \ + --coverage --coverage-settings coverage.settings.xml \ + --coverage-output-format cobertura --coverage-output full.cobertura.xml \ + || status=1 + else + status=1 fi - sleep 2 + stop_obs + echo "::endgroup::" done + exit "$status" - echo "::error::obs-websocket never started listening on ${OBS_WEBSOCKET_PORT}." - cat obs-stdout.log - exit 1 - - - name: Validate both transports - # One process, both wire formats, one OBS. Exits non-zero on failure. - env: - DISPLAY: ":99" - Obs__ServerUri: ws://localhost:4455 - Obs__Password: ${{ env.OBS_WEBSOCKET_PASSWORD }} - run: > - dotnet run --project ObsWebSocket.Example --configuration Release - -- run-transport-tests + - name: Enforce the coverage floor + # Every framework is gated, since each compiles its own conditional code paths. + if: always() + shell: bash + run: | + set -euo pipefail + python3 - <<'PY' + import glob, os, sys, xml.etree.ElementTree as ET + + floors = {"Lines": 90.0, "Branches": 80.0} + reports = sorted(glob.glob("**/full.cobertura.xml", recursive=True)) + summary = open(os.environ["GITHUB_STEP_SUMMARY"], "a", encoding="utf-8") + if not reports: + summary.write("### Coverage\n\nNo coverage report was produced.\n") + sys.exit("No coverage report was produced.") + + summary.write("### Coverage, unit and live combined\n\n") + summary.write("| Framework | Lines | Branches |\n| --- | ---: | ---: |\n") + failures = [] + for report in reports: + framework = os.path.basename(os.path.dirname(report)) + root = ET.parse(report).getroot() + rates = { + "Lines": float(root.get("line-rate") or 0) * 100, + "Branches": float(root.get("branch-rate") or 0) * 100, + } + summary.write(f"| {framework} | {rates['Lines']:.1f}% | {rates['Branches']:.1f}% |\n") + for metric, floor in floors.items(): + if rates[metric] + 1e-9 < floor: + failures.append(f"{framework}: {metric.lower()} {rates[metric]:.1f}% is below {floor:.0f}%") + + summary.write(f"\nFloors: {floors['Lines']:.0f}% lines, {floors['Branches']:.0f}% branches.\n") + if failures: + sys.exit("\n".join(failures)) + print("coverage floors met for", len(reports), "framework(s)") + PY + + - name: Upload coverage + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: coverage-full + if-no-files-found: warn + path: '**/full.cobertura.xml' - name: Collect OBS logs if: always() run: | set -euo pipefail mkdir -p obs-logs - cp obs-stdout.log obs-logs/ 2>/dev/null || true + cp obs-stdout-*.log obs-logs/ 2>/dev/null || true cp -r ~/.config/obs-studio/logs obs-logs/obs-studio-logs 2>/dev/null || true - name: Upload OBS logs diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e977d80..3f479c9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -64,15 +64,16 @@ To contribute code, you'll need to set up a local development environment: ```bash dotnet test --project ObsWebSocket.Tests -- --filter "TestCategory!=Integration" ``` -5. **(Optional) Run Integration Tests:** - * These need a running OBS with obs-websocket enabled; point `Obs__ServerUri` and - `Obs__Password` at it. - * `dotnet test --project ObsWebSocket.Tests -- --filter "TestCategory=Integration"` -6. **(Optional) Validate both wire formats against that OBS:** +5. **(Optional) Run the Integration Tests:** + These need a running OBS with obs-websocket enabled. They call every request over both wire + formats and report inconclusive when no OBS is configured. ```bash - dotnet run --project ObsWebSocket.Example -- run-transport-tests + dotnet test --project ObsWebSocket.Tests --framework net11.0 -- --test-parameter Obs.ServerUri=ws://localhost:4455 --test-parameter Obs.Password=secret ``` - Exits non-zero on the first failed check. + `Obs__ServerUri` and `Obs__Password` environment variables, or a `testsettings.local.json` + with an `Obs` section, work too. Run one framework per OBS start: the write sweep resets + video, and on Linux enumerating outputs afterwards takes OBS down (#25). CI starts a fresh OBS + for each framework. ## Checking the stub types @@ -157,8 +158,8 @@ dotnet build ObsWebSocket.Core -t:RefreshObsProtocol -p:ObsProtocolCommit= propertyInfos = []; diff --git a/ObsWebSocket.Codegen.Tasks/Generation/Emitter.HandleOverloads.cs b/ObsWebSocket.Codegen.Tasks/Generation/Emitter.HandleOverloads.cs index e653f9c..a06966f 100644 --- a/ObsWebSocket.Codegen.Tasks/Generation/Emitter.HandleOverloads.cs +++ b/ObsWebSocket.Codegen.Tasks/Generation/Emitter.HandleOverloads.cs @@ -126,6 +126,7 @@ ProtocolDefinition protocol "/// Addresses one thing in OBS, so the requests about it need not restate which." ); builder.AppendLine("/// "); + AppendGeneratedAttributes(builder); builder.AppendLine("public static class ObsWebSocketHandleExtensions"); builder.AppendLine("{"); foreach (string kind in byKind.Keys.OrderBy(k => k, StringComparer.Ordinal)) diff --git a/ObsWebSocket.Codegen.Tasks/Generation/Emitter.Helpers.cs b/ObsWebSocket.Codegen.Tasks/Generation/Emitter.Helpers.cs index 636e3e0..df36cd5 100644 --- a/ObsWebSocket.Codegen.Tasks/Generation/Emitter.Helpers.cs +++ b/ObsWebSocket.Codegen.Tasks/Generation/Emitter.Helpers.cs @@ -192,6 +192,44 @@ FieldDefinition FieldDefinition /// /// Builds the standard C# file header for generated files. /// + /// Name recorded in the generated code attribute. + private const string GeneratorName = "ObsWebSocket.Codegen"; + + /// Version recorded in the generated code attribute. + private static readonly string GeneratorVersion = + typeof(Emitter).Assembly.GetName().Version?.ToString() ?? "1.0.0.0"; + + /// + /// Marks a generated declaration, so analyzers and coverage collectors can tell it apart from + /// code someone maintains. + /// + /// + /// Only for declarations that are generated in full. A partial whose other half is hand + /// written must not carry these: both attributes apply to the whole type. + /// + /// The builder to append to. + /// Indentation to prefix each attribute with. + /// + /// for a declaration the coverage attribute is not valid on, such as + /// an enum. + /// + internal static void AppendGeneratedAttributes( + StringBuilder builder, + string indent = "", + bool excludeFromCoverage = true + ) + { + builder.AppendLine( + $"{indent}[global::System.CodeDom.Compiler.GeneratedCode(\"{GeneratorName}\", \"{GeneratorVersion}\")]" + ); + if (excludeFromCoverage) + { + builder.AppendLine( + $"{indent}[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]" + ); + } + } + private static StringBuilder BuildSourceHeader(string? fileTypeComment = null) { StringBuilder builder = new(); diff --git a/ObsWebSocket.Codegen.Tasks/Generation/Emitter.MsgPackResolver.cs b/ObsWebSocket.Codegen.Tasks/Generation/Emitter.MsgPackResolver.cs index b488f8e..9cf7541 100644 --- a/ObsWebSocket.Codegen.Tasks/Generation/Emitter.MsgPackResolver.cs +++ b/ObsWebSocket.Codegen.Tasks/Generation/Emitter.MsgPackResolver.cs @@ -161,6 +161,7 @@ private static string BuildResolverRootSource() builder.AppendLine("/// "); builder.AppendLine("/// MessagePack resolver for OBS WebSocket protocol DTOs."); builder.AppendLine("/// "); + AppendGeneratedAttributes(builder); builder.AppendLine("public sealed class ObsWebSocketMsgPackResolver : IFormatterResolver"); builder.AppendLine("{"); builder.AppendLine(" /// "); diff --git a/ObsWebSocket.Codegen.Tasks/Generation/Emitter.PayloadHandles.cs b/ObsWebSocket.Codegen.Tasks/Generation/Emitter.PayloadHandles.cs index 929c2bc..a8388ae 100644 --- a/ObsWebSocket.Codegen.Tasks/Generation/Emitter.PayloadHandles.cs +++ b/ObsWebSocket.Codegen.Tasks/Generation/Emitter.PayloadHandles.cs @@ -35,6 +35,7 @@ ProtocolDefinition protocol "/// Handles for the things an event or a response already identifies by uuid." ); builder.AppendLine("/// "); + AppendGeneratedAttributes(builder); builder.AppendLine("public static class ObsWebSocketPayloadHandles"); builder.AppendLine("{"); diff --git a/ObsWebSocket.Codegen.Tasks/Generation/Emitter.cs b/ObsWebSocket.Codegen.Tasks/Generation/Emitter.cs index 9b7aa0b..6173d71 100644 --- a/ObsWebSocket.Codegen.Tasks/Generation/Emitter.cs +++ b/ObsWebSocket.Codegen.Tasks/Generation/Emitter.cs @@ -109,6 +109,7 @@ public static void GenerateEnums(SourceProductionContext context, ProtocolDefini builder.AppendLine("[System.Flags]"); } + AppendGeneratedAttributes(builder, excludeFromCoverage: false); builder.AppendLine($"public enum {enumName} : {underlyingType}"); builder.AppendLine("{"); if (enumDef.EnumIdentifiers != null) @@ -169,6 +170,7 @@ EnumDefinition enumDef builder.AppendLine( "/// Generated from OBS WebSocket Protocol definition." ); + AppendGeneratedAttributes(builder); builder.AppendLine($"public static class {className}"); builder.AppendLine("{"); if (enumDef.EnumIdentifiers != null) @@ -364,6 +366,7 @@ private static string SnakeToPascalCase(string upperSnake) => builder.AppendLine( "/// Generated from OBS WebSocket Protocol definition." ); + AppendGeneratedAttributes(builder, excludeFromCoverage: false); builder.AppendLine($"public enum {enumName}"); builder.AppendLine("{"); foreach ((string memberIdentifier, string wire) in members) @@ -387,6 +390,7 @@ private static string SnakeToPascalCase(string upperSnake) => builder.AppendLine(); AppendXmlDocSummary(builder, $"Wire-value conversions for .", 0); + AppendGeneratedAttributes(builder); builder.AppendLine($"public static class {enumName}Extensions"); builder.AppendLine("{"); builder.AppendLine( @@ -776,6 +780,7 @@ ProtocolDefinition protocol ); } } + AppendEventDispatchTable(builder, protocol.Events); builder.AppendLine("}"); context.AddSource( "ObsWebSocketClient.Events.g.cs", @@ -783,6 +788,61 @@ ProtocolDefinition protocol ); } + /// + /// Emits the table that routes each protocol event type to its typed handler, generated from + /// the same definition as the events themselves so no event can be declared yet never raised. + /// + private static void AppendEventDispatchTable(StringBuilder builder, List events) + { + bool anyDeprecated = events.Exists(e => e.Deprecated); + + builder.AppendLine(); + builder.AppendLine( + " /// Routes each protocol event type to the handler that raises its event." + ); + builder.AppendLine( + " private static global::System.Collections.Generic.Dictionary> CreateEventDispatchTable()" + ); + builder.AppendLine(" {"); + if (anyDeprecated) + { + builder.AppendLine( + "#pragma warning disable CS0618 // Deprecated events still dispatch." + ); + } + + builder.AppendLine( + " return new global::System.Collections.Generic.Dictionary>(global::System.StringComparer.Ordinal)" + ); + builder.AppendLine(" {"); + foreach (OBSEvent eventDef in events) + { + string eventName = SanitizeIdentifier(eventDef.EventType); + string argsType = $"{GeneratedEventArgsNamespace}.{eventName}EventArgs"; + if (eventDef.DataFields?.Count > 0) + { + string payloadType = $"{GeneratedEventsNamespace}.{eventName}Payload"; + builder.AppendLine( + $" [\"{eventDef.EventType}\"] = static (c, s, d) => c.TryHandleEvent<{payloadType}, {argsType}>(s, \"{eventDef.EventType}\", d, static p => new(p), c.On{eventName})," + ); + } + else + { + builder.AppendLine( + $" [\"{eventDef.EventType}\"] = static (c, _, _) => c.On{eventName}(new {argsType}())," + ); + } + } + + builder.AppendLine(" };"); + if (anyDeprecated) + { + builder.AppendLine("#pragma warning restore CS0618"); + } + + builder.AppendLine(" }"); + } + #endregion #region Client Extension Generation (Implementation in Emitter.Helpers.cs) @@ -866,6 +926,7 @@ IGrouping group in protocol "/// Exposes the request categories defined by the OBS WebSocket protocol." ); builder.AppendLine("/// "); + AppendGeneratedAttributes(builder); builder.AppendLine("public static class ObsWebSocketClientExtensions"); builder.AppendLine("{"); foreach ((string category, string groupName) in groups) diff --git a/ObsWebSocket.Codegen.Tasks/ProtocolLock.cs b/ObsWebSocket.Codegen.Tasks/ProtocolLock.cs index 8d87743..d9d634a 100644 --- a/ObsWebSocket.Codegen.Tasks/ProtocolLock.cs +++ b/ObsWebSocket.Codegen.Tasks/ProtocolLock.cs @@ -64,7 +64,18 @@ public void Write(string protocolPath, string commit, string sha256) using JsonDocument existing = JsonDocument.Parse(File.ReadAllBytes(lockPath)); using MemoryStream buffer = new(); - using (Utf8JsonWriter writer = new(buffer, new JsonWriterOptions { Indented = true })) + // Relaxed escaping keeps the refresh command in $comment readable, and the file is + // committed with LF because .gitattributes stores it byte for byte. + using ( + Utf8JsonWriter writer = new( + buffer, + new JsonWriterOptions + { + Indented = true, + Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + } + ) + ) { writer.WriteStartObject(); foreach (JsonProperty property in existing.RootElement.EnumerateObject()) @@ -88,7 +99,7 @@ public void Write(string protocolPath, string commit, string sha256) File.WriteAllText( lockPath, - Encoding.UTF8.GetString(buffer.ToArray()) + Environment.NewLine, + Encoding.UTF8.GetString(buffer.ToArray()).Replace("\r\n", "\n") + "\n", new UTF8Encoding(false) ); } diff --git a/ObsWebSocket.Core/Generated/Client/ObsWebSocketClient.Events.g.cs b/ObsWebSocket.Core/Generated/Client/ObsWebSocketClient.Events.g.cs index 08ab208..96fc49c 100644 --- a/ObsWebSocket.Core/Generated/Client/ObsWebSocketClient.Events.g.cs +++ b/ObsWebSocket.Core/Generated/Client/ObsWebSocketClient.Events.g.cs @@ -1127,4 +1127,72 @@ private void OnCustomEvent(ObsWebSocket.Core.Events.Generated.CustomEventEventAr { CustomEvent?.Invoke(this, e); } + + /// Routes each protocol event type to the handler that raises its event. + private static global::System.Collections.Generic.Dictionary> CreateEventDispatchTable() + { + return new global::System.Collections.Generic.Dictionary>(global::System.StringComparer.Ordinal) + { + ["CanvasCreated"] = static (c, s, d) => c.TryHandleEvent(s, "CanvasCreated", d, static p => new(p), c.OnCanvasCreated), + ["CanvasRemoved"] = static (c, s, d) => c.TryHandleEvent(s, "CanvasRemoved", d, static p => new(p), c.OnCanvasRemoved), + ["CanvasNameChanged"] = static (c, s, d) => c.TryHandleEvent(s, "CanvasNameChanged", d, static p => new(p), c.OnCanvasNameChanged), + ["CurrentSceneCollectionChanging"] = static (c, s, d) => c.TryHandleEvent(s, "CurrentSceneCollectionChanging", d, static p => new(p), c.OnCurrentSceneCollectionChanging), + ["CurrentSceneCollectionChanged"] = static (c, s, d) => c.TryHandleEvent(s, "CurrentSceneCollectionChanged", d, static p => new(p), c.OnCurrentSceneCollectionChanged), + ["SceneCollectionListChanged"] = static (c, s, d) => c.TryHandleEvent(s, "SceneCollectionListChanged", d, static p => new(p), c.OnSceneCollectionListChanged), + ["CurrentProfileChanging"] = static (c, s, d) => c.TryHandleEvent(s, "CurrentProfileChanging", d, static p => new(p), c.OnCurrentProfileChanging), + ["CurrentProfileChanged"] = static (c, s, d) => c.TryHandleEvent(s, "CurrentProfileChanged", d, static p => new(p), c.OnCurrentProfileChanged), + ["ProfileListChanged"] = static (c, s, d) => c.TryHandleEvent(s, "ProfileListChanged", d, static p => new(p), c.OnProfileListChanged), + ["SourceFilterListReindexed"] = static (c, s, d) => c.TryHandleEvent(s, "SourceFilterListReindexed", d, static p => new(p), c.OnSourceFilterListReindexed), + ["SourceFilterCreated"] = static (c, s, d) => c.TryHandleEvent(s, "SourceFilterCreated", d, static p => new(p), c.OnSourceFilterCreated), + ["SourceFilterRemoved"] = static (c, s, d) => c.TryHandleEvent(s, "SourceFilterRemoved", d, static p => new(p), c.OnSourceFilterRemoved), + ["SourceFilterNameChanged"] = static (c, s, d) => c.TryHandleEvent(s, "SourceFilterNameChanged", d, static p => new(p), c.OnSourceFilterNameChanged), + ["SourceFilterSettingsChanged"] = static (c, s, d) => c.TryHandleEvent(s, "SourceFilterSettingsChanged", d, static p => new(p), c.OnSourceFilterSettingsChanged), + ["SourceFilterEnableStateChanged"] = static (c, s, d) => c.TryHandleEvent(s, "SourceFilterEnableStateChanged", d, static p => new(p), c.OnSourceFilterEnableStateChanged), + ["ExitStarted"] = static (c, _, _) => c.OnExitStarted(new ObsWebSocket.Core.Events.Generated.ExitStartedEventArgs()), + ["InputCreated"] = static (c, s, d) => c.TryHandleEvent(s, "InputCreated", d, static p => new(p), c.OnInputCreated), + ["InputRemoved"] = static (c, s, d) => c.TryHandleEvent(s, "InputRemoved", d, static p => new(p), c.OnInputRemoved), + ["InputNameChanged"] = static (c, s, d) => c.TryHandleEvent(s, "InputNameChanged", d, static p => new(p), c.OnInputNameChanged), + ["InputSettingsChanged"] = static (c, s, d) => c.TryHandleEvent(s, "InputSettingsChanged", d, static p => new(p), c.OnInputSettingsChanged), + ["InputActiveStateChanged"] = static (c, s, d) => c.TryHandleEvent(s, "InputActiveStateChanged", d, static p => new(p), c.OnInputActiveStateChanged), + ["InputShowStateChanged"] = static (c, s, d) => c.TryHandleEvent(s, "InputShowStateChanged", d, static p => new(p), c.OnInputShowStateChanged), + ["InputMuteStateChanged"] = static (c, s, d) => c.TryHandleEvent(s, "InputMuteStateChanged", d, static p => new(p), c.OnInputMuteStateChanged), + ["InputVolumeChanged"] = static (c, s, d) => c.TryHandleEvent(s, "InputVolumeChanged", d, static p => new(p), c.OnInputVolumeChanged), + ["InputAudioBalanceChanged"] = static (c, s, d) => c.TryHandleEvent(s, "InputAudioBalanceChanged", d, static p => new(p), c.OnInputAudioBalanceChanged), + ["InputAudioSyncOffsetChanged"] = static (c, s, d) => c.TryHandleEvent(s, "InputAudioSyncOffsetChanged", d, static p => new(p), c.OnInputAudioSyncOffsetChanged), + ["InputAudioTracksChanged"] = static (c, s, d) => c.TryHandleEvent(s, "InputAudioTracksChanged", d, static p => new(p), c.OnInputAudioTracksChanged), + ["InputAudioMonitorTypeChanged"] = static (c, s, d) => c.TryHandleEvent(s, "InputAudioMonitorTypeChanged", d, static p => new(p), c.OnInputAudioMonitorTypeChanged), + ["InputVolumeMeters"] = static (c, s, d) => c.TryHandleEvent(s, "InputVolumeMeters", d, static p => new(p), c.OnInputVolumeMeters), + ["MediaInputPlaybackStarted"] = static (c, s, d) => c.TryHandleEvent(s, "MediaInputPlaybackStarted", d, static p => new(p), c.OnMediaInputPlaybackStarted), + ["MediaInputPlaybackEnded"] = static (c, s, d) => c.TryHandleEvent(s, "MediaInputPlaybackEnded", d, static p => new(p), c.OnMediaInputPlaybackEnded), + ["MediaInputActionTriggered"] = static (c, s, d) => c.TryHandleEvent(s, "MediaInputActionTriggered", d, static p => new(p), c.OnMediaInputActionTriggered), + ["StreamStateChanged"] = static (c, s, d) => c.TryHandleEvent(s, "StreamStateChanged", d, static p => new(p), c.OnStreamStateChanged), + ["RecordStateChanged"] = static (c, s, d) => c.TryHandleEvent(s, "RecordStateChanged", d, static p => new(p), c.OnRecordStateChanged), + ["RecordFileChanged"] = static (c, s, d) => c.TryHandleEvent(s, "RecordFileChanged", d, static p => new(p), c.OnRecordFileChanged), + ["ReplayBufferStateChanged"] = static (c, s, d) => c.TryHandleEvent(s, "ReplayBufferStateChanged", d, static p => new(p), c.OnReplayBufferStateChanged), + ["VirtualcamStateChanged"] = static (c, s, d) => c.TryHandleEvent(s, "VirtualcamStateChanged", d, static p => new(p), c.OnVirtualcamStateChanged), + ["ReplayBufferSaved"] = static (c, s, d) => c.TryHandleEvent(s, "ReplayBufferSaved", d, static p => new(p), c.OnReplayBufferSaved), + ["SceneItemCreated"] = static (c, s, d) => c.TryHandleEvent(s, "SceneItemCreated", d, static p => new(p), c.OnSceneItemCreated), + ["SceneItemRemoved"] = static (c, s, d) => c.TryHandleEvent(s, "SceneItemRemoved", d, static p => new(p), c.OnSceneItemRemoved), + ["SceneItemListReindexed"] = static (c, s, d) => c.TryHandleEvent(s, "SceneItemListReindexed", d, static p => new(p), c.OnSceneItemListReindexed), + ["SceneItemEnableStateChanged"] = static (c, s, d) => c.TryHandleEvent(s, "SceneItemEnableStateChanged", d, static p => new(p), c.OnSceneItemEnableStateChanged), + ["SceneItemLockStateChanged"] = static (c, s, d) => c.TryHandleEvent(s, "SceneItemLockStateChanged", d, static p => new(p), c.OnSceneItemLockStateChanged), + ["SceneItemSelected"] = static (c, s, d) => c.TryHandleEvent(s, "SceneItemSelected", d, static p => new(p), c.OnSceneItemSelected), + ["SceneItemTransformChanged"] = static (c, s, d) => c.TryHandleEvent(s, "SceneItemTransformChanged", d, static p => new(p), c.OnSceneItemTransformChanged), + ["SceneCreated"] = static (c, s, d) => c.TryHandleEvent(s, "SceneCreated", d, static p => new(p), c.OnSceneCreated), + ["SceneRemoved"] = static (c, s, d) => c.TryHandleEvent(s, "SceneRemoved", d, static p => new(p), c.OnSceneRemoved), + ["SceneNameChanged"] = static (c, s, d) => c.TryHandleEvent(s, "SceneNameChanged", d, static p => new(p), c.OnSceneNameChanged), + ["CurrentProgramSceneChanged"] = static (c, s, d) => c.TryHandleEvent(s, "CurrentProgramSceneChanged", d, static p => new(p), c.OnCurrentProgramSceneChanged), + ["CurrentPreviewSceneChanged"] = static (c, s, d) => c.TryHandleEvent(s, "CurrentPreviewSceneChanged", d, static p => new(p), c.OnCurrentPreviewSceneChanged), + ["SceneListChanged"] = static (c, s, d) => c.TryHandleEvent(s, "SceneListChanged", d, static p => new(p), c.OnSceneListChanged), + ["CurrentSceneTransitionChanged"] = static (c, s, d) => c.TryHandleEvent(s, "CurrentSceneTransitionChanged", d, static p => new(p), c.OnCurrentSceneTransitionChanged), + ["CurrentSceneTransitionDurationChanged"] = static (c, s, d) => c.TryHandleEvent(s, "CurrentSceneTransitionDurationChanged", d, static p => new(p), c.OnCurrentSceneTransitionDurationChanged), + ["SceneTransitionStarted"] = static (c, s, d) => c.TryHandleEvent(s, "SceneTransitionStarted", d, static p => new(p), c.OnSceneTransitionStarted), + ["SceneTransitionEnded"] = static (c, s, d) => c.TryHandleEvent(s, "SceneTransitionEnded", d, static p => new(p), c.OnSceneTransitionEnded), + ["SceneTransitionVideoEnded"] = static (c, s, d) => c.TryHandleEvent(s, "SceneTransitionVideoEnded", d, static p => new(p), c.OnSceneTransitionVideoEnded), + ["StudioModeStateChanged"] = static (c, s, d) => c.TryHandleEvent(s, "StudioModeStateChanged", d, static p => new(p), c.OnStudioModeStateChanged), + ["ScreenshotSaved"] = static (c, s, d) => c.TryHandleEvent(s, "ScreenshotSaved", d, static p => new(p), c.OnScreenshotSaved), + ["VendorEvent"] = static (c, s, d) => c.TryHandleEvent(s, "VendorEvent", d, static p => new(p), c.OnVendorEvent), + ["CustomEvent"] = static (c, s, d) => c.TryHandleEvent(s, "CustomEvent", d, static p => new(p), c.OnCustomEvent), + }; + } } diff --git a/ObsWebSocket.Core/Generated/Client/ObsWebSocketClient.Extensions.g.cs b/ObsWebSocket.Core/Generated/Client/ObsWebSocketClient.Extensions.g.cs index d122759..0737cb2 100644 --- a/ObsWebSocket.Core/Generated/Client/ObsWebSocketClient.Extensions.g.cs +++ b/ObsWebSocket.Core/Generated/Client/ObsWebSocketClient.Extensions.g.cs @@ -3048,6 +3048,8 @@ public async Task OpenSourceProjectorAsync(ObsWebSocket.Core.Protocol.Requests.O /// /// Exposes the request categories defined by the OBS WebSocket protocol. /// +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public static class ObsWebSocketClientExtensions { extension(ObsWebSocketClient client) diff --git a/ObsWebSocket.Core/Generated/Client/ObsWebSocketClient.HandleOverloads.g.cs b/ObsWebSocket.Core/Generated/Client/ObsWebSocketClient.HandleOverloads.g.cs index 01e0823..cdce09a 100644 --- a/ObsWebSocket.Core/Generated/Client/ObsWebSocketClient.HandleOverloads.g.cs +++ b/ObsWebSocket.Core/Generated/Client/ObsWebSocketClient.HandleOverloads.g.cs @@ -1661,6 +1661,8 @@ public Task SaveScreenshotAsync( /// /// Addresses one thing in OBS, so the requests about it need not restate which. /// +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public static class ObsWebSocketHandleExtensions { extension(ObsWebSocketClient client) diff --git a/ObsWebSocket.Core/Generated/Client/ObsWebSocketClient.PayloadHandles.g.cs b/ObsWebSocket.Core/Generated/Client/ObsWebSocketClient.PayloadHandles.g.cs index 361fe77..7e40555 100644 --- a/ObsWebSocket.Core/Generated/Client/ObsWebSocketClient.PayloadHandles.g.cs +++ b/ObsWebSocket.Core/Generated/Client/ObsWebSocketClient.PayloadHandles.g.cs @@ -9,6 +9,8 @@ namespace ObsWebSocket.Core; /// /// Handles for the things an event or a response already identifies by uuid. /// +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public static class ObsWebSocketPayloadHandles { extension(ObsWebSocket.Core.Protocol.Events.CanvasCreatedPayload payload) diff --git a/ObsWebSocket.Core/Generated/Protocol/Common/NestedTypes/TriggerHotkeyByKeySequenceRequestData_KeyModifiers.g.cs b/ObsWebSocket.Core/Generated/Protocol/Common/NestedTypes/TriggerHotkeyByKeySequenceRequestData_KeyModifiers.g.cs index 95ed44f..6e1b08a 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Common/NestedTypes/TriggerHotkeyByKeySequenceRequestData_KeyModifiers.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Common/NestedTypes/TriggerHotkeyByKeySequenceRequestData_KeyModifiers.g.cs @@ -19,6 +19,8 @@ namespace ObsWebSocket.Core.Protocol.Common.NestedTypes; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record TriggerHotkeyByKeySequenceRequestData_KeyModifiers { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Events/CanvasCreated.EventPayload.g.cs b/ObsWebSocket.Core/Generated/Protocol/Events/CanvasCreated.EventPayload.g.cs index 67fa8c1..5988bca 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Events/CanvasCreated.EventPayload.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Events/CanvasCreated.EventPayload.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Events; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record CanvasCreatedPayload { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Events/CanvasNameChanged.EventPayload.g.cs b/ObsWebSocket.Core/Generated/Protocol/Events/CanvasNameChanged.EventPayload.g.cs index 82dc87d..6cd55e3 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Events/CanvasNameChanged.EventPayload.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Events/CanvasNameChanged.EventPayload.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Events; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record CanvasNameChangedPayload { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Events/CanvasRemoved.EventPayload.g.cs b/ObsWebSocket.Core/Generated/Protocol/Events/CanvasRemoved.EventPayload.g.cs index dd9f151..6978af0 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Events/CanvasRemoved.EventPayload.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Events/CanvasRemoved.EventPayload.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Events; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record CanvasRemovedPayload { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Events/CurrentPreviewSceneChanged.EventPayload.g.cs b/ObsWebSocket.Core/Generated/Protocol/Events/CurrentPreviewSceneChanged.EventPayload.g.cs index 3f44b74..7d2a19b 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Events/CurrentPreviewSceneChanged.EventPayload.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Events/CurrentPreviewSceneChanged.EventPayload.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Events; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record CurrentPreviewSceneChangedPayload { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Events/CurrentProfileChanged.EventPayload.g.cs b/ObsWebSocket.Core/Generated/Protocol/Events/CurrentProfileChanged.EventPayload.g.cs index f426b44..252a66a 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Events/CurrentProfileChanged.EventPayload.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Events/CurrentProfileChanged.EventPayload.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Events; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record CurrentProfileChangedPayload { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Events/CurrentProfileChanging.EventPayload.g.cs b/ObsWebSocket.Core/Generated/Protocol/Events/CurrentProfileChanging.EventPayload.g.cs index 75d2175..761b737 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Events/CurrentProfileChanging.EventPayload.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Events/CurrentProfileChanging.EventPayload.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Events; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record CurrentProfileChangingPayload { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Events/CurrentProgramSceneChanged.EventPayload.g.cs b/ObsWebSocket.Core/Generated/Protocol/Events/CurrentProgramSceneChanged.EventPayload.g.cs index 75b86f7..6ce3099 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Events/CurrentProgramSceneChanged.EventPayload.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Events/CurrentProgramSceneChanged.EventPayload.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Events; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record CurrentProgramSceneChangedPayload { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Events/CurrentSceneCollectionChanged.EventPayload.g.cs b/ObsWebSocket.Core/Generated/Protocol/Events/CurrentSceneCollectionChanged.EventPayload.g.cs index 739e6f7..a683237 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Events/CurrentSceneCollectionChanged.EventPayload.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Events/CurrentSceneCollectionChanged.EventPayload.g.cs @@ -24,6 +24,8 @@ namespace ObsWebSocket.Core.Protocol.Events; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record CurrentSceneCollectionChangedPayload { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Events/CurrentSceneCollectionChanging.EventPayload.g.cs b/ObsWebSocket.Core/Generated/Protocol/Events/CurrentSceneCollectionChanging.EventPayload.g.cs index bbfd793..4e82d98 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Events/CurrentSceneCollectionChanging.EventPayload.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Events/CurrentSceneCollectionChanging.EventPayload.g.cs @@ -25,6 +25,8 @@ namespace ObsWebSocket.Core.Protocol.Events; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record CurrentSceneCollectionChangingPayload { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Events/CurrentSceneTransitionChanged.EventPayload.g.cs b/ObsWebSocket.Core/Generated/Protocol/Events/CurrentSceneTransitionChanged.EventPayload.g.cs index 3cd48bf..f9eebe9 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Events/CurrentSceneTransitionChanged.EventPayload.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Events/CurrentSceneTransitionChanged.EventPayload.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Events; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record CurrentSceneTransitionChangedPayload { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Events/CurrentSceneTransitionDurationChanged.EventPayload.g.cs b/ObsWebSocket.Core/Generated/Protocol/Events/CurrentSceneTransitionDurationChanged.EventPayload.g.cs index 670e30e..c385929 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Events/CurrentSceneTransitionDurationChanged.EventPayload.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Events/CurrentSceneTransitionDurationChanged.EventPayload.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Events; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record CurrentSceneTransitionDurationChangedPayload { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Events/CustomEvent.EventPayload.g.cs b/ObsWebSocket.Core/Generated/Protocol/Events/CustomEvent.EventPayload.g.cs index 3ef7478..0e48ddb 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Events/CustomEvent.EventPayload.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Events/CustomEvent.EventPayload.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Events; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record CustomEventPayload { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Events/InputActiveStateChanged.EventPayload.g.cs b/ObsWebSocket.Core/Generated/Protocol/Events/InputActiveStateChanged.EventPayload.g.cs index 0ae0d39..2eb36a5 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Events/InputActiveStateChanged.EventPayload.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Events/InputActiveStateChanged.EventPayload.g.cs @@ -24,6 +24,8 @@ namespace ObsWebSocket.Core.Protocol.Events; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record InputActiveStateChangedPayload { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Events/InputAudioBalanceChanged.EventPayload.g.cs b/ObsWebSocket.Core/Generated/Protocol/Events/InputAudioBalanceChanged.EventPayload.g.cs index 6c1e63c..2242f90 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Events/InputAudioBalanceChanged.EventPayload.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Events/InputAudioBalanceChanged.EventPayload.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Events; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record InputAudioBalanceChangedPayload { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Events/InputAudioMonitorTypeChanged.EventPayload.g.cs b/ObsWebSocket.Core/Generated/Protocol/Events/InputAudioMonitorTypeChanged.EventPayload.g.cs index cdb1e9e..8cec4a9 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Events/InputAudioMonitorTypeChanged.EventPayload.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Events/InputAudioMonitorTypeChanged.EventPayload.g.cs @@ -28,6 +28,8 @@ namespace ObsWebSocket.Core.Protocol.Events; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record InputAudioMonitorTypeChangedPayload { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Events/InputAudioSyncOffsetChanged.EventPayload.g.cs b/ObsWebSocket.Core/Generated/Protocol/Events/InputAudioSyncOffsetChanged.EventPayload.g.cs index 6355bd2..e10c789 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Events/InputAudioSyncOffsetChanged.EventPayload.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Events/InputAudioSyncOffsetChanged.EventPayload.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Events; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record InputAudioSyncOffsetChangedPayload { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Events/InputAudioTracksChanged.EventPayload.g.cs b/ObsWebSocket.Core/Generated/Protocol/Events/InputAudioTracksChanged.EventPayload.g.cs index 0250260..b68d6be 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Events/InputAudioTracksChanged.EventPayload.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Events/InputAudioTracksChanged.EventPayload.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Events; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record InputAudioTracksChangedPayload { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Events/InputCreated.EventPayload.g.cs b/ObsWebSocket.Core/Generated/Protocol/Events/InputCreated.EventPayload.g.cs index 5f098e3..d4509b4 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Events/InputCreated.EventPayload.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Events/InputCreated.EventPayload.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Events; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record InputCreatedPayload { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Events/InputMuteStateChanged.EventPayload.g.cs b/ObsWebSocket.Core/Generated/Protocol/Events/InputMuteStateChanged.EventPayload.g.cs index fae8006..cf3e7aa 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Events/InputMuteStateChanged.EventPayload.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Events/InputMuteStateChanged.EventPayload.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Events; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record InputMuteStateChangedPayload { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Events/InputNameChanged.EventPayload.g.cs b/ObsWebSocket.Core/Generated/Protocol/Events/InputNameChanged.EventPayload.g.cs index e305c8e..671a94f 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Events/InputNameChanged.EventPayload.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Events/InputNameChanged.EventPayload.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Events; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record InputNameChangedPayload { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Events/InputRemoved.EventPayload.g.cs b/ObsWebSocket.Core/Generated/Protocol/Events/InputRemoved.EventPayload.g.cs index 5512e96..2fbb5c5 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Events/InputRemoved.EventPayload.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Events/InputRemoved.EventPayload.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Events; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record InputRemovedPayload { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Events/InputSettingsChanged.EventPayload.g.cs b/ObsWebSocket.Core/Generated/Protocol/Events/InputSettingsChanged.EventPayload.g.cs index 68fd0f6..70d38a1 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Events/InputSettingsChanged.EventPayload.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Events/InputSettingsChanged.EventPayload.g.cs @@ -24,6 +24,8 @@ namespace ObsWebSocket.Core.Protocol.Events; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record InputSettingsChangedPayload { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Events/InputShowStateChanged.EventPayload.g.cs b/ObsWebSocket.Core/Generated/Protocol/Events/InputShowStateChanged.EventPayload.g.cs index 59def4d..3ace289 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Events/InputShowStateChanged.EventPayload.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Events/InputShowStateChanged.EventPayload.g.cs @@ -24,6 +24,8 @@ namespace ObsWebSocket.Core.Protocol.Events; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record InputShowStateChangedPayload { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Events/InputVolumeChanged.EventPayload.g.cs b/ObsWebSocket.Core/Generated/Protocol/Events/InputVolumeChanged.EventPayload.g.cs index 0dad780..3482a46 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Events/InputVolumeChanged.EventPayload.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Events/InputVolumeChanged.EventPayload.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Events; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record InputVolumeChangedPayload { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Events/InputVolumeMeters.EventPayload.g.cs b/ObsWebSocket.Core/Generated/Protocol/Events/InputVolumeMeters.EventPayload.g.cs index f1d00f5..1b63c77 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Events/InputVolumeMeters.EventPayload.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Events/InputVolumeMeters.EventPayload.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Events; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record InputVolumeMetersPayload { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Events/MediaInputActionTriggered.EventPayload.g.cs b/ObsWebSocket.Core/Generated/Protocol/Events/MediaInputActionTriggered.EventPayload.g.cs index 2172bf3..3108f3c 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Events/MediaInputActionTriggered.EventPayload.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Events/MediaInputActionTriggered.EventPayload.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Events; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record MediaInputActionTriggeredPayload { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Events/MediaInputPlaybackEnded.EventPayload.g.cs b/ObsWebSocket.Core/Generated/Protocol/Events/MediaInputPlaybackEnded.EventPayload.g.cs index 20e0076..68fabf8 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Events/MediaInputPlaybackEnded.EventPayload.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Events/MediaInputPlaybackEnded.EventPayload.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Events; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record MediaInputPlaybackEndedPayload { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Events/MediaInputPlaybackStarted.EventPayload.g.cs b/ObsWebSocket.Core/Generated/Protocol/Events/MediaInputPlaybackStarted.EventPayload.g.cs index ef8b3cf..361608a 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Events/MediaInputPlaybackStarted.EventPayload.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Events/MediaInputPlaybackStarted.EventPayload.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Events; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record MediaInputPlaybackStartedPayload { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Events/ProfileListChanged.EventPayload.g.cs b/ObsWebSocket.Core/Generated/Protocol/Events/ProfileListChanged.EventPayload.g.cs index efd2f8e..e1d086a 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Events/ProfileListChanged.EventPayload.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Events/ProfileListChanged.EventPayload.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Events; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record ProfileListChangedPayload { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Events/RecordFileChanged.EventPayload.g.cs b/ObsWebSocket.Core/Generated/Protocol/Events/RecordFileChanged.EventPayload.g.cs index f07709b..58de6e3 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Events/RecordFileChanged.EventPayload.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Events/RecordFileChanged.EventPayload.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Events; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record RecordFileChangedPayload { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Events/RecordStateChanged.EventPayload.g.cs b/ObsWebSocket.Core/Generated/Protocol/Events/RecordStateChanged.EventPayload.g.cs index d931b31..9a6423d 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Events/RecordStateChanged.EventPayload.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Events/RecordStateChanged.EventPayload.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Events; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record RecordStateChangedPayload { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Events/ReplayBufferSaved.EventPayload.g.cs b/ObsWebSocket.Core/Generated/Protocol/Events/ReplayBufferSaved.EventPayload.g.cs index 83bbcda..b98a267 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Events/ReplayBufferSaved.EventPayload.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Events/ReplayBufferSaved.EventPayload.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Events; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record ReplayBufferSavedPayload { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Events/ReplayBufferStateChanged.EventPayload.g.cs b/ObsWebSocket.Core/Generated/Protocol/Events/ReplayBufferStateChanged.EventPayload.g.cs index 5dae271..b05658d 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Events/ReplayBufferStateChanged.EventPayload.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Events/ReplayBufferStateChanged.EventPayload.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Events; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record ReplayBufferStateChangedPayload { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Events/SceneCollectionListChanged.EventPayload.g.cs b/ObsWebSocket.Core/Generated/Protocol/Events/SceneCollectionListChanged.EventPayload.g.cs index 274ed52..6907739 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Events/SceneCollectionListChanged.EventPayload.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Events/SceneCollectionListChanged.EventPayload.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Events; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record SceneCollectionListChangedPayload { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Events/SceneCreated.EventPayload.g.cs b/ObsWebSocket.Core/Generated/Protocol/Events/SceneCreated.EventPayload.g.cs index 3843803..4d3a24e 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Events/SceneCreated.EventPayload.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Events/SceneCreated.EventPayload.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Events; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record SceneCreatedPayload { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Events/SceneItemCreated.EventPayload.g.cs b/ObsWebSocket.Core/Generated/Protocol/Events/SceneItemCreated.EventPayload.g.cs index c171dfd..1cfdeb4 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Events/SceneItemCreated.EventPayload.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Events/SceneItemCreated.EventPayload.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Events; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record SceneItemCreatedPayload { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Events/SceneItemEnableStateChanged.EventPayload.g.cs b/ObsWebSocket.Core/Generated/Protocol/Events/SceneItemEnableStateChanged.EventPayload.g.cs index 9dc05e7..7563d57 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Events/SceneItemEnableStateChanged.EventPayload.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Events/SceneItemEnableStateChanged.EventPayload.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Events; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record SceneItemEnableStateChangedPayload { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Events/SceneItemListReindexed.EventPayload.g.cs b/ObsWebSocket.Core/Generated/Protocol/Events/SceneItemListReindexed.EventPayload.g.cs index 5055121..cdd06c2 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Events/SceneItemListReindexed.EventPayload.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Events/SceneItemListReindexed.EventPayload.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Events; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record SceneItemListReindexedPayload { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Events/SceneItemLockStateChanged.EventPayload.g.cs b/ObsWebSocket.Core/Generated/Protocol/Events/SceneItemLockStateChanged.EventPayload.g.cs index e343624..1fc64fc 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Events/SceneItemLockStateChanged.EventPayload.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Events/SceneItemLockStateChanged.EventPayload.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Events; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record SceneItemLockStateChangedPayload { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Events/SceneItemRemoved.EventPayload.g.cs b/ObsWebSocket.Core/Generated/Protocol/Events/SceneItemRemoved.EventPayload.g.cs index 8f128c1..c77f208 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Events/SceneItemRemoved.EventPayload.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Events/SceneItemRemoved.EventPayload.g.cs @@ -24,6 +24,8 @@ namespace ObsWebSocket.Core.Protocol.Events; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record SceneItemRemovedPayload { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Events/SceneItemSelected.EventPayload.g.cs b/ObsWebSocket.Core/Generated/Protocol/Events/SceneItemSelected.EventPayload.g.cs index f5c93a5..e84fc71 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Events/SceneItemSelected.EventPayload.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Events/SceneItemSelected.EventPayload.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Events; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record SceneItemSelectedPayload { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Events/SceneItemTransformChanged.EventPayload.g.cs b/ObsWebSocket.Core/Generated/Protocol/Events/SceneItemTransformChanged.EventPayload.g.cs index 8d1ee0a..7b9957c 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Events/SceneItemTransformChanged.EventPayload.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Events/SceneItemTransformChanged.EventPayload.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Events; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record SceneItemTransformChangedPayload { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Events/SceneListChanged.EventPayload.g.cs b/ObsWebSocket.Core/Generated/Protocol/Events/SceneListChanged.EventPayload.g.cs index e54479f..27b0460 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Events/SceneListChanged.EventPayload.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Events/SceneListChanged.EventPayload.g.cs @@ -24,6 +24,8 @@ namespace ObsWebSocket.Core.Protocol.Events; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record SceneListChangedPayload { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Events/SceneNameChanged.EventPayload.g.cs b/ObsWebSocket.Core/Generated/Protocol/Events/SceneNameChanged.EventPayload.g.cs index 0b08add..1bbc7a1 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Events/SceneNameChanged.EventPayload.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Events/SceneNameChanged.EventPayload.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Events; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record SceneNameChangedPayload { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Events/SceneRemoved.EventPayload.g.cs b/ObsWebSocket.Core/Generated/Protocol/Events/SceneRemoved.EventPayload.g.cs index e40bb42..acc63d8 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Events/SceneRemoved.EventPayload.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Events/SceneRemoved.EventPayload.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Events; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record SceneRemovedPayload { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Events/SceneTransitionEnded.EventPayload.g.cs b/ObsWebSocket.Core/Generated/Protocol/Events/SceneTransitionEnded.EventPayload.g.cs index f57cf2d..468c7ba 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Events/SceneTransitionEnded.EventPayload.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Events/SceneTransitionEnded.EventPayload.g.cs @@ -24,6 +24,8 @@ namespace ObsWebSocket.Core.Protocol.Events; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record SceneTransitionEndedPayload { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Events/SceneTransitionStarted.EventPayload.g.cs b/ObsWebSocket.Core/Generated/Protocol/Events/SceneTransitionStarted.EventPayload.g.cs index 8b4d0a0..089d68a 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Events/SceneTransitionStarted.EventPayload.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Events/SceneTransitionStarted.EventPayload.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Events; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record SceneTransitionStartedPayload { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Events/SceneTransitionVideoEnded.EventPayload.g.cs b/ObsWebSocket.Core/Generated/Protocol/Events/SceneTransitionVideoEnded.EventPayload.g.cs index 5930f1c..07da5f0 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Events/SceneTransitionVideoEnded.EventPayload.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Events/SceneTransitionVideoEnded.EventPayload.g.cs @@ -27,6 +27,8 @@ namespace ObsWebSocket.Core.Protocol.Events; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record SceneTransitionVideoEndedPayload { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Events/ScreenshotSaved.EventPayload.g.cs b/ObsWebSocket.Core/Generated/Protocol/Events/ScreenshotSaved.EventPayload.g.cs index 5bb48d2..740c4fc 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Events/ScreenshotSaved.EventPayload.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Events/ScreenshotSaved.EventPayload.g.cs @@ -26,6 +26,8 @@ namespace ObsWebSocket.Core.Protocol.Events; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record ScreenshotSavedPayload { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Events/SourceFilterCreated.EventPayload.g.cs b/ObsWebSocket.Core/Generated/Protocol/Events/SourceFilterCreated.EventPayload.g.cs index ad9b2b4..b102756 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Events/SourceFilterCreated.EventPayload.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Events/SourceFilterCreated.EventPayload.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Events; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record SourceFilterCreatedPayload { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Events/SourceFilterEnableStateChanged.EventPayload.g.cs b/ObsWebSocket.Core/Generated/Protocol/Events/SourceFilterEnableStateChanged.EventPayload.g.cs index 984c893..c6e602e 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Events/SourceFilterEnableStateChanged.EventPayload.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Events/SourceFilterEnableStateChanged.EventPayload.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Events; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record SourceFilterEnableStateChangedPayload { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Events/SourceFilterListReindexed.EventPayload.g.cs b/ObsWebSocket.Core/Generated/Protocol/Events/SourceFilterListReindexed.EventPayload.g.cs index 7c77152..a55e00a 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Events/SourceFilterListReindexed.EventPayload.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Events/SourceFilterListReindexed.EventPayload.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Events; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record SourceFilterListReindexedPayload { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Events/SourceFilterNameChanged.EventPayload.g.cs b/ObsWebSocket.Core/Generated/Protocol/Events/SourceFilterNameChanged.EventPayload.g.cs index 3b19ce2..c723a97 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Events/SourceFilterNameChanged.EventPayload.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Events/SourceFilterNameChanged.EventPayload.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Events; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record SourceFilterNameChangedPayload { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Events/SourceFilterRemoved.EventPayload.g.cs b/ObsWebSocket.Core/Generated/Protocol/Events/SourceFilterRemoved.EventPayload.g.cs index c8c0d6f..f67fbe1 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Events/SourceFilterRemoved.EventPayload.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Events/SourceFilterRemoved.EventPayload.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Events; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record SourceFilterRemovedPayload { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Events/SourceFilterSettingsChanged.EventPayload.g.cs b/ObsWebSocket.Core/Generated/Protocol/Events/SourceFilterSettingsChanged.EventPayload.g.cs index 005eb19..0ca7f31 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Events/SourceFilterSettingsChanged.EventPayload.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Events/SourceFilterSettingsChanged.EventPayload.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Events; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record SourceFilterSettingsChangedPayload { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Events/StreamStateChanged.EventPayload.g.cs b/ObsWebSocket.Core/Generated/Protocol/Events/StreamStateChanged.EventPayload.g.cs index 61f570f..0471bd1 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Events/StreamStateChanged.EventPayload.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Events/StreamStateChanged.EventPayload.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Events; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record StreamStateChangedPayload { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Events/StudioModeStateChanged.EventPayload.g.cs b/ObsWebSocket.Core/Generated/Protocol/Events/StudioModeStateChanged.EventPayload.g.cs index d50029e..628a3a4 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Events/StudioModeStateChanged.EventPayload.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Events/StudioModeStateChanged.EventPayload.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Events; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record StudioModeStateChangedPayload { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Events/VendorEvent.EventPayload.g.cs b/ObsWebSocket.Core/Generated/Protocol/Events/VendorEvent.EventPayload.g.cs index fa22054..e6bc889 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Events/VendorEvent.EventPayload.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Events/VendorEvent.EventPayload.g.cs @@ -25,6 +25,8 @@ namespace ObsWebSocket.Core.Protocol.Events; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record VendorEventPayload { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Events/VirtualcamStateChanged.EventPayload.g.cs b/ObsWebSocket.Core/Generated/Protocol/Events/VirtualcamStateChanged.EventPayload.g.cs index 34b4ac7..2431151 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Events/VirtualcamStateChanged.EventPayload.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Events/VirtualcamStateChanged.EventPayload.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Events; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record VirtualcamStateChangedPayload { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Generated/EventSubscription.Enum.g.cs b/ObsWebSocket.Core/Generated/Protocol/Generated/EventSubscription.Enum.g.cs index 22b9ebb..4c33ce1 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Generated/EventSubscription.Enum.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Generated/EventSubscription.Enum.g.cs @@ -9,6 +9,7 @@ namespace ObsWebSocket.Core.Protocol.Generated; /// /// Generated from OBS WebSocket Protocol definition. [System.Flags] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] public enum EventSubscription : int { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Generated/MediaInputAction.TypedEnum.g.cs b/ObsWebSocket.Core/Generated/Protocol/Generated/MediaInputAction.TypedEnum.g.cs index f4a01e0..c834c7a 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Generated/MediaInputAction.TypedEnum.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Generated/MediaInputAction.TypedEnum.g.cs @@ -11,6 +11,7 @@ namespace ObsWebSocket.Core.Protocol.Generated; /// Typed form of the ObsMediaInputAction protocol enum. Use <see cref="MediaInputActionExtensions.ToWireValue"/> to obtain the string OBS expects. /// /// Generated from OBS WebSocket Protocol definition. +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] public enum MediaInputAction { /// Maps to OBS_WEBSOCKET_MEDIA_INPUT_ACTION_NONE. @@ -46,6 +47,8 @@ public enum MediaInputAction /// /// Wire-value conversions for <see cref="MediaInputAction"/>. /// +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public static class MediaInputActionExtensions { /// Returns the protocol string OBS expects for this value. diff --git a/ObsWebSocket.Core/Generated/Protocol/Generated/ObsMediaInputAction.Class.g.cs b/ObsWebSocket.Core/Generated/Protocol/Generated/ObsMediaInputAction.Class.g.cs index 7affa61..1743ca3 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Generated/ObsMediaInputAction.Class.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Generated/ObsMediaInputAction.Class.g.cs @@ -8,6 +8,8 @@ namespace ObsWebSocket.Core.Protocol.Generated; /// Contains string constants representing the ObsMediaInputAction options defined in the OBS WebSocket protocol. /// /// Generated from OBS WebSocket Protocol definition. +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public static class ObsMediaInputAction { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Generated/ObsOutputState.Class.g.cs b/ObsWebSocket.Core/Generated/Protocol/Generated/ObsOutputState.Class.g.cs index 1d5ce93..9bbe185 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Generated/ObsOutputState.Class.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Generated/ObsOutputState.Class.g.cs @@ -8,6 +8,8 @@ namespace ObsWebSocket.Core.Protocol.Generated; /// Contains string constants representing the ObsOutputState options defined in the OBS WebSocket protocol. /// /// Generated from OBS WebSocket Protocol definition. +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public static class ObsOutputState { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Generated/OutputState.TypedEnum.g.cs b/ObsWebSocket.Core/Generated/Protocol/Generated/OutputState.TypedEnum.g.cs index 1cef17a..56b4612 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Generated/OutputState.TypedEnum.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Generated/OutputState.TypedEnum.g.cs @@ -11,6 +11,7 @@ namespace ObsWebSocket.Core.Protocol.Generated; /// Typed form of the ObsOutputState protocol enum. Use <see cref="OutputStateExtensions.ToWireValue"/> to obtain the string OBS expects. /// /// Generated from OBS WebSocket Protocol definition. +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] public enum OutputState { /// Maps to OBS_WEBSOCKET_OUTPUT_UNKNOWN. @@ -54,6 +55,8 @@ public enum OutputState /// /// Wire-value conversions for <see cref="OutputState"/>. /// +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public static class OutputStateExtensions { /// Returns the protocol string OBS expects for this value. diff --git a/ObsWebSocket.Core/Generated/Protocol/Generated/RequestBatchExecutionType.Enum.g.cs b/ObsWebSocket.Core/Generated/Protocol/Generated/RequestBatchExecutionType.Enum.g.cs index ab08ad2..4aa6f1a 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Generated/RequestBatchExecutionType.Enum.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Generated/RequestBatchExecutionType.Enum.g.cs @@ -8,6 +8,7 @@ namespace ObsWebSocket.Core.Protocol.Generated; /// Represents the RequestBatchExecutionType options defined in the OBS WebSocket protocol. /// /// Generated from OBS WebSocket Protocol definition. +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] public enum RequestBatchExecutionType : int { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Generated/RequestStatusCode.Enum.g.cs b/ObsWebSocket.Core/Generated/Protocol/Generated/RequestStatusCode.Enum.g.cs index 982201e..01cbb4a 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Generated/RequestStatusCode.Enum.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Generated/RequestStatusCode.Enum.g.cs @@ -8,6 +8,7 @@ namespace ObsWebSocket.Core.Protocol.Generated; /// Represents the RequestStatusCode options defined in the OBS WebSocket protocol. /// /// Generated from OBS WebSocket Protocol definition. +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] public enum RequestStatusCode : int { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Generated/WebSocketCloseCode.Enum.g.cs b/ObsWebSocket.Core/Generated/Protocol/Generated/WebSocketCloseCode.Enum.g.cs index b7a2f02..0fa47b9 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Generated/WebSocketCloseCode.Enum.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Generated/WebSocketCloseCode.Enum.g.cs @@ -8,6 +8,7 @@ namespace ObsWebSocket.Core.Protocol.Generated; /// Represents the WebSocketCloseCode options defined in the OBS WebSocket protocol. /// /// Generated from OBS WebSocket Protocol definition. +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] public enum WebSocketCloseCode : int { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Generated/WebSocketOpCode.Enum.g.cs b/ObsWebSocket.Core/Generated/Protocol/Generated/WebSocketOpCode.Enum.g.cs index 1a57592..2787640 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Generated/WebSocketOpCode.Enum.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Generated/WebSocketOpCode.Enum.g.cs @@ -8,6 +8,7 @@ namespace ObsWebSocket.Core.Protocol.Generated; /// Represents the WebSocketOpCode options defined in the OBS WebSocket protocol. /// /// Generated from OBS WebSocket Protocol definition. +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] public enum WebSocketOpCode : byte { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Requests/BroadcastCustomEvent.Request.g.cs b/ObsWebSocket.Core/Generated/Protocol/Requests/BroadcastCustomEvent.Request.g.cs index 77b6175..fcaa89d 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Requests/BroadcastCustomEvent.Request.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Requests/BroadcastCustomEvent.Request.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Requests; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record BroadcastCustomEventRequestData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Requests/CallVendorRequest.Request.g.cs b/ObsWebSocket.Core/Generated/Protocol/Requests/CallVendorRequest.Request.g.cs index 8b1cdb5..83ea5f0 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Requests/CallVendorRequest.Request.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Requests/CallVendorRequest.Request.g.cs @@ -25,6 +25,8 @@ namespace ObsWebSocket.Core.Protocol.Requests; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record CallVendorRequestRequestData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Requests/CreateInput.Request.g.cs b/ObsWebSocket.Core/Generated/Protocol/Requests/CreateInput.Request.g.cs index f22e769..c8c3166 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Requests/CreateInput.Request.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Requests/CreateInput.Request.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Requests; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record CreateInputRequestData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Requests/CreateProfile.Request.g.cs b/ObsWebSocket.Core/Generated/Protocol/Requests/CreateProfile.Request.g.cs index 8e0e297..b398aa0 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Requests/CreateProfile.Request.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Requests/CreateProfile.Request.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Requests; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record CreateProfileRequestData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Requests/CreateRecordChapter.Request.g.cs b/ObsWebSocket.Core/Generated/Protocol/Requests/CreateRecordChapter.Request.g.cs index e2e3e73..d9a9c22 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Requests/CreateRecordChapter.Request.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Requests/CreateRecordChapter.Request.g.cs @@ -24,6 +24,8 @@ namespace ObsWebSocket.Core.Protocol.Requests; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record CreateRecordChapterRequestData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Requests/CreateScene.Request.g.cs b/ObsWebSocket.Core/Generated/Protocol/Requests/CreateScene.Request.g.cs index 2db7a7b..e6d87d6 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Requests/CreateScene.Request.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Requests/CreateScene.Request.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Requests; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record CreateSceneRequestData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Requests/CreateSceneCollection.Request.g.cs b/ObsWebSocket.Core/Generated/Protocol/Requests/CreateSceneCollection.Request.g.cs index 1b5ca6f..8eef12a 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Requests/CreateSceneCollection.Request.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Requests/CreateSceneCollection.Request.g.cs @@ -24,6 +24,8 @@ namespace ObsWebSocket.Core.Protocol.Requests; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record CreateSceneCollectionRequestData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Requests/CreateSceneItem.Request.g.cs b/ObsWebSocket.Core/Generated/Protocol/Requests/CreateSceneItem.Request.g.cs index 8308d4a..4f2e94a 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Requests/CreateSceneItem.Request.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Requests/CreateSceneItem.Request.g.cs @@ -24,6 +24,8 @@ namespace ObsWebSocket.Core.Protocol.Requests; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record CreateSceneItemRequestData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Requests/CreateSourceFilter.Request.g.cs b/ObsWebSocket.Core/Generated/Protocol/Requests/CreateSourceFilter.Request.g.cs index 578649f..877c9dd 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Requests/CreateSourceFilter.Request.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Requests/CreateSourceFilter.Request.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Requests; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record CreateSourceFilterRequestData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Requests/DuplicateSceneItem.Request.g.cs b/ObsWebSocket.Core/Generated/Protocol/Requests/DuplicateSceneItem.Request.g.cs index fe51c9a..6887ce6 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Requests/DuplicateSceneItem.Request.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Requests/DuplicateSceneItem.Request.g.cs @@ -24,6 +24,8 @@ namespace ObsWebSocket.Core.Protocol.Requests; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record DuplicateSceneItemRequestData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Requests/GetGroupSceneItemList.Request.g.cs b/ObsWebSocket.Core/Generated/Protocol/Requests/GetGroupSceneItemList.Request.g.cs index 63cc149..383ddfd 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Requests/GetGroupSceneItemList.Request.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Requests/GetGroupSceneItemList.Request.g.cs @@ -26,6 +26,8 @@ namespace ObsWebSocket.Core.Protocol.Requests; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record GetGroupSceneItemListRequestData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Requests/GetInputAudioBalance.Request.g.cs b/ObsWebSocket.Core/Generated/Protocol/Requests/GetInputAudioBalance.Request.g.cs index 44a090f..482587a 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Requests/GetInputAudioBalance.Request.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Requests/GetInputAudioBalance.Request.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Requests; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record GetInputAudioBalanceRequestData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Requests/GetInputAudioMonitorType.Request.g.cs b/ObsWebSocket.Core/Generated/Protocol/Requests/GetInputAudioMonitorType.Request.g.cs index 9d0dc3a..414ab5a 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Requests/GetInputAudioMonitorType.Request.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Requests/GetInputAudioMonitorType.Request.g.cs @@ -28,6 +28,8 @@ namespace ObsWebSocket.Core.Protocol.Requests; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record GetInputAudioMonitorTypeRequestData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Requests/GetInputAudioSyncOffset.Request.g.cs b/ObsWebSocket.Core/Generated/Protocol/Requests/GetInputAudioSyncOffset.Request.g.cs index 1f76b12..b4b0fab 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Requests/GetInputAudioSyncOffset.Request.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Requests/GetInputAudioSyncOffset.Request.g.cs @@ -24,6 +24,8 @@ namespace ObsWebSocket.Core.Protocol.Requests; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record GetInputAudioSyncOffsetRequestData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Requests/GetInputAudioTracks.Request.g.cs b/ObsWebSocket.Core/Generated/Protocol/Requests/GetInputAudioTracks.Request.g.cs index 8f8b7e8..3cc8c69 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Requests/GetInputAudioTracks.Request.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Requests/GetInputAudioTracks.Request.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Requests; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record GetInputAudioTracksRequestData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Requests/GetInputDefaultSettings.Request.g.cs b/ObsWebSocket.Core/Generated/Protocol/Requests/GetInputDefaultSettings.Request.g.cs index 6001d33..facadee 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Requests/GetInputDefaultSettings.Request.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Requests/GetInputDefaultSettings.Request.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Requests; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record GetInputDefaultSettingsRequestData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Requests/GetInputDeinterlaceFieldOrder.Request.g.cs b/ObsWebSocket.Core/Generated/Protocol/Requests/GetInputDeinterlaceFieldOrder.Request.g.cs index 439431f..e9a164c 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Requests/GetInputDeinterlaceFieldOrder.Request.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Requests/GetInputDeinterlaceFieldOrder.Request.g.cs @@ -29,6 +29,8 @@ namespace ObsWebSocket.Core.Protocol.Requests; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record GetInputDeinterlaceFieldOrderRequestData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Requests/GetInputDeinterlaceMode.Request.g.cs b/ObsWebSocket.Core/Generated/Protocol/Requests/GetInputDeinterlaceMode.Request.g.cs index 859e174..51e0bc4 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Requests/GetInputDeinterlaceMode.Request.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Requests/GetInputDeinterlaceMode.Request.g.cs @@ -36,6 +36,8 @@ namespace ObsWebSocket.Core.Protocol.Requests; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record GetInputDeinterlaceModeRequestData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Requests/GetInputKindList.Request.g.cs b/ObsWebSocket.Core/Generated/Protocol/Requests/GetInputKindList.Request.g.cs index 064a1ab..e6d9edd 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Requests/GetInputKindList.Request.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Requests/GetInputKindList.Request.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Requests; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record GetInputKindListRequestData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Requests/GetInputList.Request.g.cs b/ObsWebSocket.Core/Generated/Protocol/Requests/GetInputList.Request.g.cs index 764a282..6cdd45f 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Requests/GetInputList.Request.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Requests/GetInputList.Request.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Requests; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record GetInputListRequestData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Requests/GetInputMute.Request.g.cs b/ObsWebSocket.Core/Generated/Protocol/Requests/GetInputMute.Request.g.cs index 6ebcf4f..446894d 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Requests/GetInputMute.Request.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Requests/GetInputMute.Request.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Requests; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record GetInputMuteRequestData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Requests/GetInputPropertiesListPropertyItems.Request.g.cs b/ObsWebSocket.Core/Generated/Protocol/Requests/GetInputPropertiesListPropertyItems.Request.g.cs index 82ab546..398269a 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Requests/GetInputPropertiesListPropertyItems.Request.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Requests/GetInputPropertiesListPropertyItems.Request.g.cs @@ -24,6 +24,8 @@ namespace ObsWebSocket.Core.Protocol.Requests; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record GetInputPropertiesListPropertyItemsRequestData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Requests/GetInputSettings.Request.g.cs b/ObsWebSocket.Core/Generated/Protocol/Requests/GetInputSettings.Request.g.cs index 1bb3b41..a9ebe14 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Requests/GetInputSettings.Request.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Requests/GetInputSettings.Request.g.cs @@ -24,6 +24,8 @@ namespace ObsWebSocket.Core.Protocol.Requests; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record GetInputSettingsRequestData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Requests/GetInputVolume.Request.g.cs b/ObsWebSocket.Core/Generated/Protocol/Requests/GetInputVolume.Request.g.cs index dbc6bd7..3880523 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Requests/GetInputVolume.Request.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Requests/GetInputVolume.Request.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Requests; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record GetInputVolumeRequestData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Requests/GetMediaInputStatus.Request.g.cs b/ObsWebSocket.Core/Generated/Protocol/Requests/GetMediaInputStatus.Request.g.cs index 30a11ad..656e928 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Requests/GetMediaInputStatus.Request.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Requests/GetMediaInputStatus.Request.g.cs @@ -33,6 +33,8 @@ namespace ObsWebSocket.Core.Protocol.Requests; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record GetMediaInputStatusRequestData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Requests/GetOutputSettings.Request.g.cs b/ObsWebSocket.Core/Generated/Protocol/Requests/GetOutputSettings.Request.g.cs index e5c0b73..fb96638 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Requests/GetOutputSettings.Request.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Requests/GetOutputSettings.Request.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Requests; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record GetOutputSettingsRequestData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Requests/GetOutputStatus.Request.g.cs b/ObsWebSocket.Core/Generated/Protocol/Requests/GetOutputStatus.Request.g.cs index 6c6d57c..32e9131 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Requests/GetOutputStatus.Request.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Requests/GetOutputStatus.Request.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Requests; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record GetOutputStatusRequestData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Requests/GetPersistentData.Request.g.cs b/ObsWebSocket.Core/Generated/Protocol/Requests/GetPersistentData.Request.g.cs index 8fe90ea..ff9c61c 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Requests/GetPersistentData.Request.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Requests/GetPersistentData.Request.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Requests; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record GetPersistentDataRequestData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Requests/GetProfileParameter.Request.g.cs b/ObsWebSocket.Core/Generated/Protocol/Requests/GetProfileParameter.Request.g.cs index 6dd8a89..f261d71 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Requests/GetProfileParameter.Request.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Requests/GetProfileParameter.Request.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Requests; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record GetProfileParameterRequestData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Requests/GetSceneItemBlendMode.Request.g.cs b/ObsWebSocket.Core/Generated/Protocol/Requests/GetSceneItemBlendMode.Request.g.cs index ee54579..bdf0f2c 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Requests/GetSceneItemBlendMode.Request.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Requests/GetSceneItemBlendMode.Request.g.cs @@ -34,6 +34,8 @@ namespace ObsWebSocket.Core.Protocol.Requests; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record GetSceneItemBlendModeRequestData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Requests/GetSceneItemEnabled.Request.g.cs b/ObsWebSocket.Core/Generated/Protocol/Requests/GetSceneItemEnabled.Request.g.cs index 9f10718..aba081b 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Requests/GetSceneItemEnabled.Request.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Requests/GetSceneItemEnabled.Request.g.cs @@ -24,6 +24,8 @@ namespace ObsWebSocket.Core.Protocol.Requests; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record GetSceneItemEnabledRequestData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Requests/GetSceneItemId.Request.g.cs b/ObsWebSocket.Core/Generated/Protocol/Requests/GetSceneItemId.Request.g.cs index 0b1b90f..52b1593 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Requests/GetSceneItemId.Request.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Requests/GetSceneItemId.Request.g.cs @@ -24,6 +24,8 @@ namespace ObsWebSocket.Core.Protocol.Requests; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record GetSceneItemIdRequestData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Requests/GetSceneItemIndex.Request.g.cs b/ObsWebSocket.Core/Generated/Protocol/Requests/GetSceneItemIndex.Request.g.cs index b3194da..27d3bea 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Requests/GetSceneItemIndex.Request.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Requests/GetSceneItemIndex.Request.g.cs @@ -26,6 +26,8 @@ namespace ObsWebSocket.Core.Protocol.Requests; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record GetSceneItemIndexRequestData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Requests/GetSceneItemList.Request.g.cs b/ObsWebSocket.Core/Generated/Protocol/Requests/GetSceneItemList.Request.g.cs index 9a1ebb2..fc92904 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Requests/GetSceneItemList.Request.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Requests/GetSceneItemList.Request.g.cs @@ -24,6 +24,8 @@ namespace ObsWebSocket.Core.Protocol.Requests; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record GetSceneItemListRequestData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Requests/GetSceneItemLocked.Request.g.cs b/ObsWebSocket.Core/Generated/Protocol/Requests/GetSceneItemLocked.Request.g.cs index 2436b6b..6e46f7b 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Requests/GetSceneItemLocked.Request.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Requests/GetSceneItemLocked.Request.g.cs @@ -24,6 +24,8 @@ namespace ObsWebSocket.Core.Protocol.Requests; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record GetSceneItemLockedRequestData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Requests/GetSceneItemSource.Request.g.cs b/ObsWebSocket.Core/Generated/Protocol/Requests/GetSceneItemSource.Request.g.cs index 9416886..06159bd 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Requests/GetSceneItemSource.Request.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Requests/GetSceneItemSource.Request.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Requests; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record GetSceneItemSourceRequestData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Requests/GetSceneItemTransform.Request.g.cs b/ObsWebSocket.Core/Generated/Protocol/Requests/GetSceneItemTransform.Request.g.cs index 8ef3d56..2be1c3c 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Requests/GetSceneItemTransform.Request.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Requests/GetSceneItemTransform.Request.g.cs @@ -24,6 +24,8 @@ namespace ObsWebSocket.Core.Protocol.Requests; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record GetSceneItemTransformRequestData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Requests/GetSceneList.Request.g.cs b/ObsWebSocket.Core/Generated/Protocol/Requests/GetSceneList.Request.g.cs index 678e541..e488d41 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Requests/GetSceneList.Request.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Requests/GetSceneList.Request.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Requests; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record GetSceneListRequestData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Requests/GetSceneSceneTransitionOverride.Request.g.cs b/ObsWebSocket.Core/Generated/Protocol/Requests/GetSceneSceneTransitionOverride.Request.g.cs index af5523c..eab4b4e 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Requests/GetSceneSceneTransitionOverride.Request.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Requests/GetSceneSceneTransitionOverride.Request.g.cs @@ -24,6 +24,8 @@ namespace ObsWebSocket.Core.Protocol.Requests; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record GetSceneSceneTransitionOverrideRequestData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Requests/GetSourceActive.Request.g.cs b/ObsWebSocket.Core/Generated/Protocol/Requests/GetSourceActive.Request.g.cs index 3bcd47b..a9e3d81 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Requests/GetSourceActive.Request.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Requests/GetSourceActive.Request.g.cs @@ -24,6 +24,8 @@ namespace ObsWebSocket.Core.Protocol.Requests; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record GetSourceActiveRequestData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Requests/GetSourceFilter.Request.g.cs b/ObsWebSocket.Core/Generated/Protocol/Requests/GetSourceFilter.Request.g.cs index 9e4caa0..5ff1240 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Requests/GetSourceFilter.Request.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Requests/GetSourceFilter.Request.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Requests; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record GetSourceFilterRequestData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Requests/GetSourceFilterDefaultSettings.Request.g.cs b/ObsWebSocket.Core/Generated/Protocol/Requests/GetSourceFilterDefaultSettings.Request.g.cs index da3b79d..40b8427 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Requests/GetSourceFilterDefaultSettings.Request.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Requests/GetSourceFilterDefaultSettings.Request.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Requests; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record GetSourceFilterDefaultSettingsRequestData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Requests/GetSourceFilterList.Request.g.cs b/ObsWebSocket.Core/Generated/Protocol/Requests/GetSourceFilterList.Request.g.cs index 8543ae4..3976455 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Requests/GetSourceFilterList.Request.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Requests/GetSourceFilterList.Request.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Requests; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record GetSourceFilterListRequestData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Requests/GetSourceScreenshot.Request.g.cs b/ObsWebSocket.Core/Generated/Protocol/Requests/GetSourceScreenshot.Request.g.cs index 5160ad6..3af110d 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Requests/GetSourceScreenshot.Request.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Requests/GetSourceScreenshot.Request.g.cs @@ -27,6 +27,8 @@ namespace ObsWebSocket.Core.Protocol.Requests; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record GetSourceScreenshotRequestData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Requests/OffsetMediaInputCursor.Request.g.cs b/ObsWebSocket.Core/Generated/Protocol/Requests/OffsetMediaInputCursor.Request.g.cs index 9cb4b13..3942a25 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Requests/OffsetMediaInputCursor.Request.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Requests/OffsetMediaInputCursor.Request.g.cs @@ -24,6 +24,8 @@ namespace ObsWebSocket.Core.Protocol.Requests; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record OffsetMediaInputCursorRequestData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Requests/OpenInputFiltersDialog.Request.g.cs b/ObsWebSocket.Core/Generated/Protocol/Requests/OpenInputFiltersDialog.Request.g.cs index 57f41da..1e5454d 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Requests/OpenInputFiltersDialog.Request.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Requests/OpenInputFiltersDialog.Request.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Requests; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record OpenInputFiltersDialogRequestData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Requests/OpenInputInteractDialog.Request.g.cs b/ObsWebSocket.Core/Generated/Protocol/Requests/OpenInputInteractDialog.Request.g.cs index 7ee9959..b116399 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Requests/OpenInputInteractDialog.Request.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Requests/OpenInputInteractDialog.Request.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Requests; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record OpenInputInteractDialogRequestData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Requests/OpenInputPropertiesDialog.Request.g.cs b/ObsWebSocket.Core/Generated/Protocol/Requests/OpenInputPropertiesDialog.Request.g.cs index a4cfb86..8980bcb 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Requests/OpenInputPropertiesDialog.Request.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Requests/OpenInputPropertiesDialog.Request.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Requests; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record OpenInputPropertiesDialogRequestData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Requests/OpenSourceProjector.Request.g.cs b/ObsWebSocket.Core/Generated/Protocol/Requests/OpenSourceProjector.Request.g.cs index 248b87c..5224dd1 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Requests/OpenSourceProjector.Request.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Requests/OpenSourceProjector.Request.g.cs @@ -24,6 +24,8 @@ namespace ObsWebSocket.Core.Protocol.Requests; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record OpenSourceProjectorRequestData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Requests/OpenVideoMixProjector.Request.g.cs b/ObsWebSocket.Core/Generated/Protocol/Requests/OpenVideoMixProjector.Request.g.cs index aebe232..f87d351 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Requests/OpenVideoMixProjector.Request.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Requests/OpenVideoMixProjector.Request.g.cs @@ -30,6 +30,8 @@ namespace ObsWebSocket.Core.Protocol.Requests; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record OpenVideoMixProjectorRequestData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Requests/PressInputPropertiesButton.Request.g.cs b/ObsWebSocket.Core/Generated/Protocol/Requests/PressInputPropertiesButton.Request.g.cs index 9f6b981..70b2707 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Requests/PressInputPropertiesButton.Request.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Requests/PressInputPropertiesButton.Request.g.cs @@ -28,6 +28,8 @@ namespace ObsWebSocket.Core.Protocol.Requests; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record PressInputPropertiesButtonRequestData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Requests/RemoveInput.Request.g.cs b/ObsWebSocket.Core/Generated/Protocol/Requests/RemoveInput.Request.g.cs index 76f5c91..d7b7c66 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Requests/RemoveInput.Request.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Requests/RemoveInput.Request.g.cs @@ -24,6 +24,8 @@ namespace ObsWebSocket.Core.Protocol.Requests; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record RemoveInputRequestData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Requests/RemoveProfile.Request.g.cs b/ObsWebSocket.Core/Generated/Protocol/Requests/RemoveProfile.Request.g.cs index 00f366e..3cd4cff 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Requests/RemoveProfile.Request.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Requests/RemoveProfile.Request.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Requests; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record RemoveProfileRequestData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Requests/RemoveScene.Request.g.cs b/ObsWebSocket.Core/Generated/Protocol/Requests/RemoveScene.Request.g.cs index d140e90..20aacd2 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Requests/RemoveScene.Request.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Requests/RemoveScene.Request.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Requests; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record RemoveSceneRequestData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Requests/RemoveSceneItem.Request.g.cs b/ObsWebSocket.Core/Generated/Protocol/Requests/RemoveSceneItem.Request.g.cs index 938fc95..9442da5 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Requests/RemoveSceneItem.Request.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Requests/RemoveSceneItem.Request.g.cs @@ -24,6 +24,8 @@ namespace ObsWebSocket.Core.Protocol.Requests; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record RemoveSceneItemRequestData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Requests/RemoveSourceFilter.Request.g.cs b/ObsWebSocket.Core/Generated/Protocol/Requests/RemoveSourceFilter.Request.g.cs index 758ed90..82e5e59 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Requests/RemoveSourceFilter.Request.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Requests/RemoveSourceFilter.Request.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Requests; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record RemoveSourceFilterRequestData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Requests/SaveSourceScreenshot.Request.g.cs b/ObsWebSocket.Core/Generated/Protocol/Requests/SaveSourceScreenshot.Request.g.cs index a559bd4..1d43d83 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Requests/SaveSourceScreenshot.Request.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Requests/SaveSourceScreenshot.Request.g.cs @@ -27,6 +27,8 @@ namespace ObsWebSocket.Core.Protocol.Requests; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record SaveSourceScreenshotRequestData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Requests/SendStreamCaption.Request.g.cs b/ObsWebSocket.Core/Generated/Protocol/Requests/SendStreamCaption.Request.g.cs index 5bcb262..7ee6db3 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Requests/SendStreamCaption.Request.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Requests/SendStreamCaption.Request.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Requests; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record SendStreamCaptionRequestData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Requests/SetCurrentPreviewScene.Request.g.cs b/ObsWebSocket.Core/Generated/Protocol/Requests/SetCurrentPreviewScene.Request.g.cs index de2e878..9fc610b 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Requests/SetCurrentPreviewScene.Request.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Requests/SetCurrentPreviewScene.Request.g.cs @@ -24,6 +24,8 @@ namespace ObsWebSocket.Core.Protocol.Requests; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record SetCurrentPreviewSceneRequestData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Requests/SetCurrentProfile.Request.g.cs b/ObsWebSocket.Core/Generated/Protocol/Requests/SetCurrentProfile.Request.g.cs index 081e878..b5cc630 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Requests/SetCurrentProfile.Request.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Requests/SetCurrentProfile.Request.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Requests; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record SetCurrentProfileRequestData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Requests/SetCurrentProgramScene.Request.g.cs b/ObsWebSocket.Core/Generated/Protocol/Requests/SetCurrentProgramScene.Request.g.cs index 4931130..16843a9 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Requests/SetCurrentProgramScene.Request.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Requests/SetCurrentProgramScene.Request.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Requests; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record SetCurrentProgramSceneRequestData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Requests/SetCurrentSceneCollection.Request.g.cs b/ObsWebSocket.Core/Generated/Protocol/Requests/SetCurrentSceneCollection.Request.g.cs index 0285860..5c48420 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Requests/SetCurrentSceneCollection.Request.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Requests/SetCurrentSceneCollection.Request.g.cs @@ -24,6 +24,8 @@ namespace ObsWebSocket.Core.Protocol.Requests; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record SetCurrentSceneCollectionRequestData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Requests/SetCurrentSceneTransition.Request.g.cs b/ObsWebSocket.Core/Generated/Protocol/Requests/SetCurrentSceneTransition.Request.g.cs index e15c084..adf7f50 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Requests/SetCurrentSceneTransition.Request.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Requests/SetCurrentSceneTransition.Request.g.cs @@ -24,6 +24,8 @@ namespace ObsWebSocket.Core.Protocol.Requests; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record SetCurrentSceneTransitionRequestData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Requests/SetCurrentSceneTransitionDuration.Request.g.cs b/ObsWebSocket.Core/Generated/Protocol/Requests/SetCurrentSceneTransitionDuration.Request.g.cs index c5aca9b..12cda59 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Requests/SetCurrentSceneTransitionDuration.Request.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Requests/SetCurrentSceneTransitionDuration.Request.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Requests; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record SetCurrentSceneTransitionDurationRequestData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Requests/SetCurrentSceneTransitionSettings.Request.g.cs b/ObsWebSocket.Core/Generated/Protocol/Requests/SetCurrentSceneTransitionSettings.Request.g.cs index 82b2b97..c20d092 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Requests/SetCurrentSceneTransitionSettings.Request.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Requests/SetCurrentSceneTransitionSettings.Request.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Requests; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record SetCurrentSceneTransitionSettingsRequestData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Requests/SetInputAudioBalance.Request.g.cs b/ObsWebSocket.Core/Generated/Protocol/Requests/SetInputAudioBalance.Request.g.cs index c7aef6f..853457e 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Requests/SetInputAudioBalance.Request.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Requests/SetInputAudioBalance.Request.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Requests; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record SetInputAudioBalanceRequestData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Requests/SetInputAudioMonitorType.Request.g.cs b/ObsWebSocket.Core/Generated/Protocol/Requests/SetInputAudioMonitorType.Request.g.cs index 70f2170..f9e7b3e 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Requests/SetInputAudioMonitorType.Request.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Requests/SetInputAudioMonitorType.Request.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Requests; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record SetInputAudioMonitorTypeRequestData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Requests/SetInputAudioSyncOffset.Request.g.cs b/ObsWebSocket.Core/Generated/Protocol/Requests/SetInputAudioSyncOffset.Request.g.cs index f8038e1..4862a4a 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Requests/SetInputAudioSyncOffset.Request.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Requests/SetInputAudioSyncOffset.Request.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Requests; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record SetInputAudioSyncOffsetRequestData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Requests/SetInputAudioTracks.Request.g.cs b/ObsWebSocket.Core/Generated/Protocol/Requests/SetInputAudioTracks.Request.g.cs index 6982b5d..31834f2 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Requests/SetInputAudioTracks.Request.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Requests/SetInputAudioTracks.Request.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Requests; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record SetInputAudioTracksRequestData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Requests/SetInputDeinterlaceFieldOrder.Request.g.cs b/ObsWebSocket.Core/Generated/Protocol/Requests/SetInputDeinterlaceFieldOrder.Request.g.cs index e45cdf3..0675457 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Requests/SetInputDeinterlaceFieldOrder.Request.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Requests/SetInputDeinterlaceFieldOrder.Request.g.cs @@ -24,6 +24,8 @@ namespace ObsWebSocket.Core.Protocol.Requests; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record SetInputDeinterlaceFieldOrderRequestData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Requests/SetInputDeinterlaceMode.Request.g.cs b/ObsWebSocket.Core/Generated/Protocol/Requests/SetInputDeinterlaceMode.Request.g.cs index 2c78f64..d9c1fa6 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Requests/SetInputDeinterlaceMode.Request.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Requests/SetInputDeinterlaceMode.Request.g.cs @@ -24,6 +24,8 @@ namespace ObsWebSocket.Core.Protocol.Requests; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record SetInputDeinterlaceModeRequestData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Requests/SetInputMute.Request.g.cs b/ObsWebSocket.Core/Generated/Protocol/Requests/SetInputMute.Request.g.cs index d0f21ec..bebce54 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Requests/SetInputMute.Request.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Requests/SetInputMute.Request.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Requests; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record SetInputMuteRequestData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Requests/SetInputName.Request.g.cs b/ObsWebSocket.Core/Generated/Protocol/Requests/SetInputName.Request.g.cs index 529cab0..38d1403 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Requests/SetInputName.Request.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Requests/SetInputName.Request.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Requests; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record SetInputNameRequestData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Requests/SetInputSettings.Request.g.cs b/ObsWebSocket.Core/Generated/Protocol/Requests/SetInputSettings.Request.g.cs index 5a46049..c53d680 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Requests/SetInputSettings.Request.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Requests/SetInputSettings.Request.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Requests; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record SetInputSettingsRequestData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Requests/SetInputVolume.Request.g.cs b/ObsWebSocket.Core/Generated/Protocol/Requests/SetInputVolume.Request.g.cs index 0b6d9b9..3c2c584 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Requests/SetInputVolume.Request.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Requests/SetInputVolume.Request.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Requests; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record SetInputVolumeRequestData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Requests/SetMediaInputCursor.Request.g.cs b/ObsWebSocket.Core/Generated/Protocol/Requests/SetMediaInputCursor.Request.g.cs index 58a3d4d..e7756ec 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Requests/SetMediaInputCursor.Request.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Requests/SetMediaInputCursor.Request.g.cs @@ -24,6 +24,8 @@ namespace ObsWebSocket.Core.Protocol.Requests; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record SetMediaInputCursorRequestData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Requests/SetOutputSettings.Request.g.cs b/ObsWebSocket.Core/Generated/Protocol/Requests/SetOutputSettings.Request.g.cs index c6efe5d..616d9e3 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Requests/SetOutputSettings.Request.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Requests/SetOutputSettings.Request.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Requests; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record SetOutputSettingsRequestData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Requests/SetPersistentData.Request.g.cs b/ObsWebSocket.Core/Generated/Protocol/Requests/SetPersistentData.Request.g.cs index 25a91a8..7b2b381 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Requests/SetPersistentData.Request.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Requests/SetPersistentData.Request.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Requests; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record SetPersistentDataRequestData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Requests/SetProfileParameter.Request.g.cs b/ObsWebSocket.Core/Generated/Protocol/Requests/SetProfileParameter.Request.g.cs index 8a3d674..837989e 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Requests/SetProfileParameter.Request.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Requests/SetProfileParameter.Request.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Requests; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record SetProfileParameterRequestData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Requests/SetRecordDirectory.Request.g.cs b/ObsWebSocket.Core/Generated/Protocol/Requests/SetRecordDirectory.Request.g.cs index 495240e..5805a26 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Requests/SetRecordDirectory.Request.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Requests/SetRecordDirectory.Request.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Requests; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record SetRecordDirectoryRequestData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Requests/SetSceneItemBlendMode.Request.g.cs b/ObsWebSocket.Core/Generated/Protocol/Requests/SetSceneItemBlendMode.Request.g.cs index 6c62eae..b69bcdd 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Requests/SetSceneItemBlendMode.Request.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Requests/SetSceneItemBlendMode.Request.g.cs @@ -24,6 +24,8 @@ namespace ObsWebSocket.Core.Protocol.Requests; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record SetSceneItemBlendModeRequestData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Requests/SetSceneItemEnabled.Request.g.cs b/ObsWebSocket.Core/Generated/Protocol/Requests/SetSceneItemEnabled.Request.g.cs index ce9870d..6e589f3 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Requests/SetSceneItemEnabled.Request.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Requests/SetSceneItemEnabled.Request.g.cs @@ -24,6 +24,8 @@ namespace ObsWebSocket.Core.Protocol.Requests; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record SetSceneItemEnabledRequestData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Requests/SetSceneItemIndex.Request.g.cs b/ObsWebSocket.Core/Generated/Protocol/Requests/SetSceneItemIndex.Request.g.cs index 8505b1b..c8907dc 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Requests/SetSceneItemIndex.Request.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Requests/SetSceneItemIndex.Request.g.cs @@ -24,6 +24,8 @@ namespace ObsWebSocket.Core.Protocol.Requests; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record SetSceneItemIndexRequestData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Requests/SetSceneItemLocked.Request.g.cs b/ObsWebSocket.Core/Generated/Protocol/Requests/SetSceneItemLocked.Request.g.cs index 7c96467..24acfa4 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Requests/SetSceneItemLocked.Request.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Requests/SetSceneItemLocked.Request.g.cs @@ -24,6 +24,8 @@ namespace ObsWebSocket.Core.Protocol.Requests; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record SetSceneItemLockedRequestData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Requests/SetSceneItemTransform.Request.g.cs b/ObsWebSocket.Core/Generated/Protocol/Requests/SetSceneItemTransform.Request.g.cs index e3c662a..2460a3e 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Requests/SetSceneItemTransform.Request.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Requests/SetSceneItemTransform.Request.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Requests; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record SetSceneItemTransformRequestData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Requests/SetSceneName.Request.g.cs b/ObsWebSocket.Core/Generated/Protocol/Requests/SetSceneName.Request.g.cs index 1f67bdd..72e90a4 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Requests/SetSceneName.Request.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Requests/SetSceneName.Request.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Requests; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record SetSceneNameRequestData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Requests/SetSceneSceneTransitionOverride.Request.g.cs b/ObsWebSocket.Core/Generated/Protocol/Requests/SetSceneSceneTransitionOverride.Request.g.cs index 5a14346..b73de61 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Requests/SetSceneSceneTransitionOverride.Request.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Requests/SetSceneSceneTransitionOverride.Request.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Requests; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record SetSceneSceneTransitionOverrideRequestData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Requests/SetSourceFilterEnabled.Request.g.cs b/ObsWebSocket.Core/Generated/Protocol/Requests/SetSourceFilterEnabled.Request.g.cs index 5a7cde5..f20e9eb 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Requests/SetSourceFilterEnabled.Request.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Requests/SetSourceFilterEnabled.Request.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Requests; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record SetSourceFilterEnabledRequestData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Requests/SetSourceFilterIndex.Request.g.cs b/ObsWebSocket.Core/Generated/Protocol/Requests/SetSourceFilterIndex.Request.g.cs index ebe980f..0e35556 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Requests/SetSourceFilterIndex.Request.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Requests/SetSourceFilterIndex.Request.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Requests; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record SetSourceFilterIndexRequestData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Requests/SetSourceFilterName.Request.g.cs b/ObsWebSocket.Core/Generated/Protocol/Requests/SetSourceFilterName.Request.g.cs index df8636b..9483a52 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Requests/SetSourceFilterName.Request.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Requests/SetSourceFilterName.Request.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Requests; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record SetSourceFilterNameRequestData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Requests/SetSourceFilterSettings.Request.g.cs b/ObsWebSocket.Core/Generated/Protocol/Requests/SetSourceFilterSettings.Request.g.cs index 3daec7b..9541637 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Requests/SetSourceFilterSettings.Request.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Requests/SetSourceFilterSettings.Request.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Requests; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record SetSourceFilterSettingsRequestData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Requests/SetStreamServiceSettings.Request.g.cs b/ObsWebSocket.Core/Generated/Protocol/Requests/SetStreamServiceSettings.Request.g.cs index 5a5f443..777436e 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Requests/SetStreamServiceSettings.Request.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Requests/SetStreamServiceSettings.Request.g.cs @@ -24,6 +24,8 @@ namespace ObsWebSocket.Core.Protocol.Requests; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record SetStreamServiceSettingsRequestData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Requests/SetStudioModeEnabled.Request.g.cs b/ObsWebSocket.Core/Generated/Protocol/Requests/SetStudioModeEnabled.Request.g.cs index 748a00b..34cb038 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Requests/SetStudioModeEnabled.Request.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Requests/SetStudioModeEnabled.Request.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Requests; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record SetStudioModeEnabledRequestData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Requests/SetTBarPosition.Request.g.cs b/ObsWebSocket.Core/Generated/Protocol/Requests/SetTBarPosition.Request.g.cs index fffc0c3..7775432 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Requests/SetTBarPosition.Request.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Requests/SetTBarPosition.Request.g.cs @@ -24,6 +24,8 @@ namespace ObsWebSocket.Core.Protocol.Requests; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record SetTBarPositionRequestData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Requests/SetVideoSettings.Request.g.cs b/ObsWebSocket.Core/Generated/Protocol/Requests/SetVideoSettings.Request.g.cs index 5749d84..73dff03 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Requests/SetVideoSettings.Request.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Requests/SetVideoSettings.Request.g.cs @@ -24,6 +24,8 @@ namespace ObsWebSocket.Core.Protocol.Requests; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record SetVideoSettingsRequestData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Requests/Sleep.Request.g.cs b/ObsWebSocket.Core/Generated/Protocol/Requests/Sleep.Request.g.cs index 485e8c5..21a5f0e 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Requests/Sleep.Request.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Requests/Sleep.Request.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Requests; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record SleepRequestData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Requests/StartOutput.Request.g.cs b/ObsWebSocket.Core/Generated/Protocol/Requests/StartOutput.Request.g.cs index ab4c52b..2d9919d 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Requests/StartOutput.Request.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Requests/StartOutput.Request.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Requests; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record StartOutputRequestData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Requests/StopOutput.Request.g.cs b/ObsWebSocket.Core/Generated/Protocol/Requests/StopOutput.Request.g.cs index 63a95a8..da0da92 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Requests/StopOutput.Request.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Requests/StopOutput.Request.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Requests; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record StopOutputRequestData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Requests/ToggleInputMute.Request.g.cs b/ObsWebSocket.Core/Generated/Protocol/Requests/ToggleInputMute.Request.g.cs index abd8a0d..1210bf1 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Requests/ToggleInputMute.Request.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Requests/ToggleInputMute.Request.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Requests; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record ToggleInputMuteRequestData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Requests/ToggleOutput.Request.g.cs b/ObsWebSocket.Core/Generated/Protocol/Requests/ToggleOutput.Request.g.cs index 8c742bb..9239edf 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Requests/ToggleOutput.Request.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Requests/ToggleOutput.Request.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Requests; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record ToggleOutputRequestData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Requests/TriggerHotkeyByKeySequence.Request.g.cs b/ObsWebSocket.Core/Generated/Protocol/Requests/TriggerHotkeyByKeySequence.Request.g.cs index 88c0f3a..0a04d74 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Requests/TriggerHotkeyByKeySequence.Request.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Requests/TriggerHotkeyByKeySequence.Request.g.cs @@ -24,6 +24,8 @@ namespace ObsWebSocket.Core.Protocol.Requests; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record TriggerHotkeyByKeySequenceRequestData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Requests/TriggerHotkeyByName.Request.g.cs b/ObsWebSocket.Core/Generated/Protocol/Requests/TriggerHotkeyByName.Request.g.cs index 1c4d9ec..15fceef 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Requests/TriggerHotkeyByName.Request.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Requests/TriggerHotkeyByName.Request.g.cs @@ -24,6 +24,8 @@ namespace ObsWebSocket.Core.Protocol.Requests; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record TriggerHotkeyByNameRequestData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Requests/TriggerMediaInputAction.Request.g.cs b/ObsWebSocket.Core/Generated/Protocol/Requests/TriggerMediaInputAction.Request.g.cs index 9751408..2c04cce 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Requests/TriggerMediaInputAction.Request.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Requests/TriggerMediaInputAction.Request.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Requests; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record TriggerMediaInputActionRequestData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Responses/CallVendorRequest.Response.g.cs b/ObsWebSocket.Core/Generated/Protocol/Responses/CallVendorRequest.Response.g.cs index 27be420..e05fca9 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Responses/CallVendorRequest.Response.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Responses/CallVendorRequest.Response.g.cs @@ -25,6 +25,8 @@ namespace ObsWebSocket.Core.Protocol.Responses; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record CallVendorRequestResponseData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Responses/CreateInput.Response.g.cs b/ObsWebSocket.Core/Generated/Protocol/Responses/CreateInput.Response.g.cs index 150deb4..fbdf2a9 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Responses/CreateInput.Response.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Responses/CreateInput.Response.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Responses; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record CreateInputResponseData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Responses/CreateScene.Response.g.cs b/ObsWebSocket.Core/Generated/Protocol/Responses/CreateScene.Response.g.cs index dc5a303..2a220ed 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Responses/CreateScene.Response.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Responses/CreateScene.Response.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Responses; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record CreateSceneResponseData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Responses/CreateSceneItem.Response.g.cs b/ObsWebSocket.Core/Generated/Protocol/Responses/CreateSceneItem.Response.g.cs index 275bd4b..224687a 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Responses/CreateSceneItem.Response.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Responses/CreateSceneItem.Response.g.cs @@ -24,6 +24,8 @@ namespace ObsWebSocket.Core.Protocol.Responses; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record CreateSceneItemResponseData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Responses/DuplicateSceneItem.Response.g.cs b/ObsWebSocket.Core/Generated/Protocol/Responses/DuplicateSceneItem.Response.g.cs index 22cb4cd..91b1d06 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Responses/DuplicateSceneItem.Response.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Responses/DuplicateSceneItem.Response.g.cs @@ -24,6 +24,8 @@ namespace ObsWebSocket.Core.Protocol.Responses; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record DuplicateSceneItemResponseData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Responses/GetCanvasList.Response.g.cs b/ObsWebSocket.Core/Generated/Protocol/Responses/GetCanvasList.Response.g.cs index c65492c..119ec63 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Responses/GetCanvasList.Response.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Responses/GetCanvasList.Response.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Responses; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record GetCanvasListResponseData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Responses/GetCurrentPreviewScene.Response.g.cs b/ObsWebSocket.Core/Generated/Protocol/Responses/GetCurrentPreviewScene.Response.g.cs index b5d17f7..9ed5091 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Responses/GetCurrentPreviewScene.Response.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Responses/GetCurrentPreviewScene.Response.g.cs @@ -26,6 +26,8 @@ namespace ObsWebSocket.Core.Protocol.Responses; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record GetCurrentPreviewSceneResponseData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Responses/GetCurrentProgramScene.Response.g.cs b/ObsWebSocket.Core/Generated/Protocol/Responses/GetCurrentProgramScene.Response.g.cs index 85c179d..12e6588 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Responses/GetCurrentProgramScene.Response.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Responses/GetCurrentProgramScene.Response.g.cs @@ -26,6 +26,8 @@ namespace ObsWebSocket.Core.Protocol.Responses; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record GetCurrentProgramSceneResponseData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Responses/GetCurrentSceneTransition.Response.g.cs b/ObsWebSocket.Core/Generated/Protocol/Responses/GetCurrentSceneTransition.Response.g.cs index 0edd818..24e6be0 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Responses/GetCurrentSceneTransition.Response.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Responses/GetCurrentSceneTransition.Response.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Responses; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record GetCurrentSceneTransitionResponseData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Responses/GetCurrentSceneTransitionCursor.Response.g.cs b/ObsWebSocket.Core/Generated/Protocol/Responses/GetCurrentSceneTransitionCursor.Response.g.cs index 8fd89de..8830b1c 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Responses/GetCurrentSceneTransitionCursor.Response.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Responses/GetCurrentSceneTransitionCursor.Response.g.cs @@ -24,6 +24,8 @@ namespace ObsWebSocket.Core.Protocol.Responses; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record GetCurrentSceneTransitionCursorResponseData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Responses/GetGroupList.Response.g.cs b/ObsWebSocket.Core/Generated/Protocol/Responses/GetGroupList.Response.g.cs index 6320ecc..4e27a27 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Responses/GetGroupList.Response.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Responses/GetGroupList.Response.g.cs @@ -24,6 +24,8 @@ namespace ObsWebSocket.Core.Protocol.Responses; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record GetGroupListResponseData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Responses/GetGroupSceneItemList.Response.g.cs b/ObsWebSocket.Core/Generated/Protocol/Responses/GetGroupSceneItemList.Response.g.cs index 7a8e22e..a740ff6 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Responses/GetGroupSceneItemList.Response.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Responses/GetGroupSceneItemList.Response.g.cs @@ -26,6 +26,8 @@ namespace ObsWebSocket.Core.Protocol.Responses; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record GetGroupSceneItemListResponseData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Responses/GetHotkeyList.Response.g.cs b/ObsWebSocket.Core/Generated/Protocol/Responses/GetHotkeyList.Response.g.cs index 8341aaf..7ee027d 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Responses/GetHotkeyList.Response.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Responses/GetHotkeyList.Response.g.cs @@ -24,6 +24,8 @@ namespace ObsWebSocket.Core.Protocol.Responses; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record GetHotkeyListResponseData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Responses/GetInputAudioBalance.Response.g.cs b/ObsWebSocket.Core/Generated/Protocol/Responses/GetInputAudioBalance.Response.g.cs index 4e912e6..201afcf 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Responses/GetInputAudioBalance.Response.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Responses/GetInputAudioBalance.Response.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Responses; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record GetInputAudioBalanceResponseData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Responses/GetInputAudioMonitorType.Response.g.cs b/ObsWebSocket.Core/Generated/Protocol/Responses/GetInputAudioMonitorType.Response.g.cs index 8229b39..2ccc42d 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Responses/GetInputAudioMonitorType.Response.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Responses/GetInputAudioMonitorType.Response.g.cs @@ -28,6 +28,8 @@ namespace ObsWebSocket.Core.Protocol.Responses; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record GetInputAudioMonitorTypeResponseData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Responses/GetInputAudioSyncOffset.Response.g.cs b/ObsWebSocket.Core/Generated/Protocol/Responses/GetInputAudioSyncOffset.Response.g.cs index 01caf06..bce7395 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Responses/GetInputAudioSyncOffset.Response.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Responses/GetInputAudioSyncOffset.Response.g.cs @@ -24,6 +24,8 @@ namespace ObsWebSocket.Core.Protocol.Responses; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record GetInputAudioSyncOffsetResponseData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Responses/GetInputAudioTracks.Response.g.cs b/ObsWebSocket.Core/Generated/Protocol/Responses/GetInputAudioTracks.Response.g.cs index 9e730b6..b6228ef 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Responses/GetInputAudioTracks.Response.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Responses/GetInputAudioTracks.Response.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Responses; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record GetInputAudioTracksResponseData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Responses/GetInputDefaultSettings.Response.g.cs b/ObsWebSocket.Core/Generated/Protocol/Responses/GetInputDefaultSettings.Response.g.cs index a6b73fa..56fd23e 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Responses/GetInputDefaultSettings.Response.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Responses/GetInputDefaultSettings.Response.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Responses; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record GetInputDefaultSettingsResponseData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Responses/GetInputDeinterlaceFieldOrder.Response.g.cs b/ObsWebSocket.Core/Generated/Protocol/Responses/GetInputDeinterlaceFieldOrder.Response.g.cs index 2b8a903..85e2cfe 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Responses/GetInputDeinterlaceFieldOrder.Response.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Responses/GetInputDeinterlaceFieldOrder.Response.g.cs @@ -29,6 +29,8 @@ namespace ObsWebSocket.Core.Protocol.Responses; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record GetInputDeinterlaceFieldOrderResponseData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Responses/GetInputDeinterlaceMode.Response.g.cs b/ObsWebSocket.Core/Generated/Protocol/Responses/GetInputDeinterlaceMode.Response.g.cs index 05fbbe8..1d439ce 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Responses/GetInputDeinterlaceMode.Response.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Responses/GetInputDeinterlaceMode.Response.g.cs @@ -36,6 +36,8 @@ namespace ObsWebSocket.Core.Protocol.Responses; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record GetInputDeinterlaceModeResponseData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Responses/GetInputKindList.Response.g.cs b/ObsWebSocket.Core/Generated/Protocol/Responses/GetInputKindList.Response.g.cs index 6d26527..da3a0de 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Responses/GetInputKindList.Response.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Responses/GetInputKindList.Response.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Responses; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record GetInputKindListResponseData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Responses/GetInputList.Response.g.cs b/ObsWebSocket.Core/Generated/Protocol/Responses/GetInputList.Response.g.cs index ea28a4b..fb2125f 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Responses/GetInputList.Response.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Responses/GetInputList.Response.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Responses; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record GetInputListResponseData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Responses/GetInputMute.Response.g.cs b/ObsWebSocket.Core/Generated/Protocol/Responses/GetInputMute.Response.g.cs index 9b98dbd..fb86afa 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Responses/GetInputMute.Response.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Responses/GetInputMute.Response.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Responses; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record GetInputMuteResponseData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Responses/GetInputPropertiesListPropertyItems.Response.g.cs b/ObsWebSocket.Core/Generated/Protocol/Responses/GetInputPropertiesListPropertyItems.Response.g.cs index 4998a08..066c4b6 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Responses/GetInputPropertiesListPropertyItems.Response.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Responses/GetInputPropertiesListPropertyItems.Response.g.cs @@ -24,6 +24,8 @@ namespace ObsWebSocket.Core.Protocol.Responses; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record GetInputPropertiesListPropertyItemsResponseData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Responses/GetInputSettings.Response.g.cs b/ObsWebSocket.Core/Generated/Protocol/Responses/GetInputSettings.Response.g.cs index 3d5a27c..89ae5f7 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Responses/GetInputSettings.Response.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Responses/GetInputSettings.Response.g.cs @@ -24,6 +24,8 @@ namespace ObsWebSocket.Core.Protocol.Responses; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record GetInputSettingsResponseData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Responses/GetInputVolume.Response.g.cs b/ObsWebSocket.Core/Generated/Protocol/Responses/GetInputVolume.Response.g.cs index 6a96413..6604d8a 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Responses/GetInputVolume.Response.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Responses/GetInputVolume.Response.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Responses; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record GetInputVolumeResponseData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Responses/GetLastReplayBufferReplay.Response.g.cs b/ObsWebSocket.Core/Generated/Protocol/Responses/GetLastReplayBufferReplay.Response.g.cs index 49ee625..030154c 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Responses/GetLastReplayBufferReplay.Response.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Responses/GetLastReplayBufferReplay.Response.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Responses; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record GetLastReplayBufferReplayResponseData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Responses/GetMediaInputStatus.Response.g.cs b/ObsWebSocket.Core/Generated/Protocol/Responses/GetMediaInputStatus.Response.g.cs index 5555c1d..e0e9ed6 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Responses/GetMediaInputStatus.Response.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Responses/GetMediaInputStatus.Response.g.cs @@ -33,6 +33,8 @@ namespace ObsWebSocket.Core.Protocol.Responses; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record GetMediaInputStatusResponseData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Responses/GetMonitorList.Response.g.cs b/ObsWebSocket.Core/Generated/Protocol/Responses/GetMonitorList.Response.g.cs index cfe1115..ef50d2f 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Responses/GetMonitorList.Response.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Responses/GetMonitorList.Response.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Responses; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record GetMonitorListResponseData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Responses/GetOutputList.Response.g.cs b/ObsWebSocket.Core/Generated/Protocol/Responses/GetOutputList.Response.g.cs index 3c4f07a..8d94432 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Responses/GetOutputList.Response.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Responses/GetOutputList.Response.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Responses; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record GetOutputListResponseData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Responses/GetOutputSettings.Response.g.cs b/ObsWebSocket.Core/Generated/Protocol/Responses/GetOutputSettings.Response.g.cs index ad059ff..31b25ee 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Responses/GetOutputSettings.Response.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Responses/GetOutputSettings.Response.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Responses; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record GetOutputSettingsResponseData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Responses/GetOutputStatus.Response.g.cs b/ObsWebSocket.Core/Generated/Protocol/Responses/GetOutputStatus.Response.g.cs index ff0ca93..8321a70 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Responses/GetOutputStatus.Response.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Responses/GetOutputStatus.Response.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Responses; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record GetOutputStatusResponseData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Responses/GetPersistentData.Response.g.cs b/ObsWebSocket.Core/Generated/Protocol/Responses/GetPersistentData.Response.g.cs index 3372fd2..b195478 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Responses/GetPersistentData.Response.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Responses/GetPersistentData.Response.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Responses; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record GetPersistentDataResponseData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Responses/GetProfileList.Response.g.cs b/ObsWebSocket.Core/Generated/Protocol/Responses/GetProfileList.Response.g.cs index 8c6a5db..92d9702 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Responses/GetProfileList.Response.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Responses/GetProfileList.Response.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Responses; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record GetProfileListResponseData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Responses/GetProfileParameter.Response.g.cs b/ObsWebSocket.Core/Generated/Protocol/Responses/GetProfileParameter.Response.g.cs index c036fa6..55df49c 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Responses/GetProfileParameter.Response.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Responses/GetProfileParameter.Response.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Responses; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record GetProfileParameterResponseData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Responses/GetRecordDirectory.Response.g.cs b/ObsWebSocket.Core/Generated/Protocol/Responses/GetRecordDirectory.Response.g.cs index 28cb753..47ad529 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Responses/GetRecordDirectory.Response.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Responses/GetRecordDirectory.Response.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Responses; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record GetRecordDirectoryResponseData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Responses/GetRecordStatus.Response.g.cs b/ObsWebSocket.Core/Generated/Protocol/Responses/GetRecordStatus.Response.g.cs index 7f0499f..edd8fbc 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Responses/GetRecordStatus.Response.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Responses/GetRecordStatus.Response.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Responses; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record GetRecordStatusResponseData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Responses/GetReplayBufferStatus.Response.g.cs b/ObsWebSocket.Core/Generated/Protocol/Responses/GetReplayBufferStatus.Response.g.cs index cb31557..81e2804 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Responses/GetReplayBufferStatus.Response.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Responses/GetReplayBufferStatus.Response.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Responses; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record GetReplayBufferStatusResponseData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Responses/GetSceneCollectionList.Response.g.cs b/ObsWebSocket.Core/Generated/Protocol/Responses/GetSceneCollectionList.Response.g.cs index be07285..abd8576 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Responses/GetSceneCollectionList.Response.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Responses/GetSceneCollectionList.Response.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Responses; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record GetSceneCollectionListResponseData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Responses/GetSceneItemBlendMode.Response.g.cs b/ObsWebSocket.Core/Generated/Protocol/Responses/GetSceneItemBlendMode.Response.g.cs index 678af56..f529a6e 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Responses/GetSceneItemBlendMode.Response.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Responses/GetSceneItemBlendMode.Response.g.cs @@ -34,6 +34,8 @@ namespace ObsWebSocket.Core.Protocol.Responses; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record GetSceneItemBlendModeResponseData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Responses/GetSceneItemEnabled.Response.g.cs b/ObsWebSocket.Core/Generated/Protocol/Responses/GetSceneItemEnabled.Response.g.cs index e276a29..9996410 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Responses/GetSceneItemEnabled.Response.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Responses/GetSceneItemEnabled.Response.g.cs @@ -24,6 +24,8 @@ namespace ObsWebSocket.Core.Protocol.Responses; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record GetSceneItemEnabledResponseData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Responses/GetSceneItemId.Response.g.cs b/ObsWebSocket.Core/Generated/Protocol/Responses/GetSceneItemId.Response.g.cs index dbf827b..924e5f2 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Responses/GetSceneItemId.Response.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Responses/GetSceneItemId.Response.g.cs @@ -24,6 +24,8 @@ namespace ObsWebSocket.Core.Protocol.Responses; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record GetSceneItemIdResponseData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Responses/GetSceneItemIndex.Response.g.cs b/ObsWebSocket.Core/Generated/Protocol/Responses/GetSceneItemIndex.Response.g.cs index a7d86c6..ed8a4d1 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Responses/GetSceneItemIndex.Response.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Responses/GetSceneItemIndex.Response.g.cs @@ -26,6 +26,8 @@ namespace ObsWebSocket.Core.Protocol.Responses; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record GetSceneItemIndexResponseData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Responses/GetSceneItemList.Response.g.cs b/ObsWebSocket.Core/Generated/Protocol/Responses/GetSceneItemList.Response.g.cs index 00ecc2a..058824d 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Responses/GetSceneItemList.Response.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Responses/GetSceneItemList.Response.g.cs @@ -24,6 +24,8 @@ namespace ObsWebSocket.Core.Protocol.Responses; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record GetSceneItemListResponseData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Responses/GetSceneItemLocked.Response.g.cs b/ObsWebSocket.Core/Generated/Protocol/Responses/GetSceneItemLocked.Response.g.cs index 5725d11..c6e6534 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Responses/GetSceneItemLocked.Response.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Responses/GetSceneItemLocked.Response.g.cs @@ -24,6 +24,8 @@ namespace ObsWebSocket.Core.Protocol.Responses; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record GetSceneItemLockedResponseData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Responses/GetSceneItemSource.Response.g.cs b/ObsWebSocket.Core/Generated/Protocol/Responses/GetSceneItemSource.Response.g.cs index 16616b1..c43fab1 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Responses/GetSceneItemSource.Response.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Responses/GetSceneItemSource.Response.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Responses; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record GetSceneItemSourceResponseData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Responses/GetSceneItemTransform.Response.g.cs b/ObsWebSocket.Core/Generated/Protocol/Responses/GetSceneItemTransform.Response.g.cs index dad352d..58a4d9e 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Responses/GetSceneItemTransform.Response.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Responses/GetSceneItemTransform.Response.g.cs @@ -24,6 +24,8 @@ namespace ObsWebSocket.Core.Protocol.Responses; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record GetSceneItemTransformResponseData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Responses/GetSceneList.Response.g.cs b/ObsWebSocket.Core/Generated/Protocol/Responses/GetSceneList.Response.g.cs index a293190..05067f1 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Responses/GetSceneList.Response.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Responses/GetSceneList.Response.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Responses; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record GetSceneListResponseData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Responses/GetSceneSceneTransitionOverride.Response.g.cs b/ObsWebSocket.Core/Generated/Protocol/Responses/GetSceneSceneTransitionOverride.Response.g.cs index 2dde8b6..31a8d45 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Responses/GetSceneSceneTransitionOverride.Response.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Responses/GetSceneSceneTransitionOverride.Response.g.cs @@ -24,6 +24,8 @@ namespace ObsWebSocket.Core.Protocol.Responses; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record GetSceneSceneTransitionOverrideResponseData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Responses/GetSceneTransitionList.Response.g.cs b/ObsWebSocket.Core/Generated/Protocol/Responses/GetSceneTransitionList.Response.g.cs index 18a4fe1..27cdf49 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Responses/GetSceneTransitionList.Response.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Responses/GetSceneTransitionList.Response.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Responses; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record GetSceneTransitionListResponseData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Responses/GetSourceActive.Response.g.cs b/ObsWebSocket.Core/Generated/Protocol/Responses/GetSourceActive.Response.g.cs index dff6b88..abb8bed 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Responses/GetSourceActive.Response.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Responses/GetSourceActive.Response.g.cs @@ -24,6 +24,8 @@ namespace ObsWebSocket.Core.Protocol.Responses; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record GetSourceActiveResponseData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Responses/GetSourceFilter.Response.g.cs b/ObsWebSocket.Core/Generated/Protocol/Responses/GetSourceFilter.Response.g.cs index a965564..718a2e7 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Responses/GetSourceFilter.Response.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Responses/GetSourceFilter.Response.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Responses; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record GetSourceFilterResponseData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Responses/GetSourceFilterDefaultSettings.Response.g.cs b/ObsWebSocket.Core/Generated/Protocol/Responses/GetSourceFilterDefaultSettings.Response.g.cs index 3a8582f..07913b5 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Responses/GetSourceFilterDefaultSettings.Response.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Responses/GetSourceFilterDefaultSettings.Response.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Responses; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record GetSourceFilterDefaultSettingsResponseData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Responses/GetSourceFilterKindList.Response.g.cs b/ObsWebSocket.Core/Generated/Protocol/Responses/GetSourceFilterKindList.Response.g.cs index be71509..ac00b2d 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Responses/GetSourceFilterKindList.Response.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Responses/GetSourceFilterKindList.Response.g.cs @@ -24,6 +24,8 @@ namespace ObsWebSocket.Core.Protocol.Responses; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record GetSourceFilterKindListResponseData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Responses/GetSourceFilterList.Response.g.cs b/ObsWebSocket.Core/Generated/Protocol/Responses/GetSourceFilterList.Response.g.cs index 8e48091..4444192 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Responses/GetSourceFilterList.Response.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Responses/GetSourceFilterList.Response.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Responses; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record GetSourceFilterListResponseData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Responses/GetSourceScreenshot.Response.g.cs b/ObsWebSocket.Core/Generated/Protocol/Responses/GetSourceScreenshot.Response.g.cs index 7374ff4..2ba8af5 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Responses/GetSourceScreenshot.Response.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Responses/GetSourceScreenshot.Response.g.cs @@ -27,6 +27,8 @@ namespace ObsWebSocket.Core.Protocol.Responses; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record GetSourceScreenshotResponseData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Responses/GetSpecialInputs.Response.g.cs b/ObsWebSocket.Core/Generated/Protocol/Responses/GetSpecialInputs.Response.g.cs index 4181309..aa46f2f 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Responses/GetSpecialInputs.Response.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Responses/GetSpecialInputs.Response.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Responses; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record GetSpecialInputsResponseData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Responses/GetStats.Response.g.cs b/ObsWebSocket.Core/Generated/Protocol/Responses/GetStats.Response.g.cs index 0b4fa0d..f0ba3ef 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Responses/GetStats.Response.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Responses/GetStats.Response.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Responses; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record GetStatsResponseData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Responses/GetStreamServiceSettings.Response.g.cs b/ObsWebSocket.Core/Generated/Protocol/Responses/GetStreamServiceSettings.Response.g.cs index 4a01c1d..b9e7b70 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Responses/GetStreamServiceSettings.Response.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Responses/GetStreamServiceSettings.Response.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Responses; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record GetStreamServiceSettingsResponseData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Responses/GetStreamStatus.Response.g.cs b/ObsWebSocket.Core/Generated/Protocol/Responses/GetStreamStatus.Response.g.cs index 221d672..0b4dc2f 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Responses/GetStreamStatus.Response.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Responses/GetStreamStatus.Response.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Responses; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record GetStreamStatusResponseData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Responses/GetStudioModeEnabled.Response.g.cs b/ObsWebSocket.Core/Generated/Protocol/Responses/GetStudioModeEnabled.Response.g.cs index 1380d34..bb84239 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Responses/GetStudioModeEnabled.Response.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Responses/GetStudioModeEnabled.Response.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Responses; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record GetStudioModeEnabledResponseData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Responses/GetTransitionKindList.Response.g.cs b/ObsWebSocket.Core/Generated/Protocol/Responses/GetTransitionKindList.Response.g.cs index 0bc3ca9..8e1a518 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Responses/GetTransitionKindList.Response.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Responses/GetTransitionKindList.Response.g.cs @@ -24,6 +24,8 @@ namespace ObsWebSocket.Core.Protocol.Responses; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record GetTransitionKindListResponseData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Responses/GetVersion.Response.g.cs b/ObsWebSocket.Core/Generated/Protocol/Responses/GetVersion.Response.g.cs index 3bdc83c..6ddf509 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Responses/GetVersion.Response.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Responses/GetVersion.Response.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Responses; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record GetVersionResponseData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Responses/GetVideoSettings.Response.g.cs b/ObsWebSocket.Core/Generated/Protocol/Responses/GetVideoSettings.Response.g.cs index 4becccc..8511155 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Responses/GetVideoSettings.Response.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Responses/GetVideoSettings.Response.g.cs @@ -24,6 +24,8 @@ namespace ObsWebSocket.Core.Protocol.Responses; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record GetVideoSettingsResponseData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Responses/GetVirtualCamStatus.Response.g.cs b/ObsWebSocket.Core/Generated/Protocol/Responses/GetVirtualCamStatus.Response.g.cs index c193582..facd36a 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Responses/GetVirtualCamStatus.Response.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Responses/GetVirtualCamStatus.Response.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Responses; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record GetVirtualCamStatusResponseData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Responses/StopRecord.Response.g.cs b/ObsWebSocket.Core/Generated/Protocol/Responses/StopRecord.Response.g.cs index d79896b..0694004 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Responses/StopRecord.Response.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Responses/StopRecord.Response.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Responses; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record StopRecordResponseData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Responses/ToggleInputMute.Response.g.cs b/ObsWebSocket.Core/Generated/Protocol/Responses/ToggleInputMute.Response.g.cs index f30b3c8..2bdc42e 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Responses/ToggleInputMute.Response.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Responses/ToggleInputMute.Response.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Responses; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record ToggleInputMuteResponseData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Responses/ToggleOutput.Response.g.cs b/ObsWebSocket.Core/Generated/Protocol/Responses/ToggleOutput.Response.g.cs index 908b023..9a1f5a7 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Responses/ToggleOutput.Response.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Responses/ToggleOutput.Response.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Responses; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record ToggleOutputResponseData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Responses/ToggleRecord.Response.g.cs b/ObsWebSocket.Core/Generated/Protocol/Responses/ToggleRecord.Response.g.cs index 47d2064..72525aa 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Responses/ToggleRecord.Response.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Responses/ToggleRecord.Response.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Responses; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record ToggleRecordResponseData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Responses/ToggleReplayBuffer.Response.g.cs b/ObsWebSocket.Core/Generated/Protocol/Responses/ToggleReplayBuffer.Response.g.cs index e93f3b6..53a3b59 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Responses/ToggleReplayBuffer.Response.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Responses/ToggleReplayBuffer.Response.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Responses; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record ToggleReplayBufferResponseData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Responses/ToggleStream.Response.g.cs b/ObsWebSocket.Core/Generated/Protocol/Responses/ToggleStream.Response.g.cs index f0aa025..82eded2 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Responses/ToggleStream.Response.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Responses/ToggleStream.Response.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Responses; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record ToggleStreamResponseData { /// diff --git a/ObsWebSocket.Core/Generated/Protocol/Responses/ToggleVirtualCam.Response.g.cs b/ObsWebSocket.Core/Generated/Protocol/Responses/ToggleVirtualCam.Response.g.cs index 21da62c..14559ee 100644 --- a/ObsWebSocket.Core/Generated/Protocol/Responses/ToggleVirtualCam.Response.g.cs +++ b/ObsWebSocket.Core/Generated/Protocol/Responses/ToggleVirtualCam.Response.g.cs @@ -22,6 +22,8 @@ namespace ObsWebSocket.Core.Protocol.Responses; /// Generated from obs-websocket protocol definition. #pragma warning disable CS8618 [MessagePackObject] +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed partial record ToggleVirtualCamResponseData { /// diff --git a/ObsWebSocket.Core/Generated/Serialization/ObsWebSocketMsgPackResolver.g.cs b/ObsWebSocket.Core/Generated/Serialization/ObsWebSocketMsgPackResolver.g.cs index 0136433..5e3d09f 100644 --- a/ObsWebSocket.Core/Generated/Serialization/ObsWebSocketMsgPackResolver.g.cs +++ b/ObsWebSocket.Core/Generated/Serialization/ObsWebSocketMsgPackResolver.g.cs @@ -12,6 +12,8 @@ namespace ObsWebSocket.Core.Serialization; /// /// MessagePack resolver for OBS WebSocket protocol DTOs. /// +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed class ObsWebSocketMsgPackResolver : IFormatterResolver { /// diff --git a/ObsWebSocket.Core/Groups/ConfigGroup.cs b/ObsWebSocket.Core/Groups/ConfigGroup.cs index fc1cc8c..05599ba 100644 --- a/ObsWebSocket.Core/Groups/ConfigGroup.cs +++ b/ObsWebSocket.Core/Groups/ConfigGroup.cs @@ -172,10 +172,7 @@ is RequestStatusCode.ResourceNotFound or RequestStatusCode.InvalidRequestField ) { - client._logger.LogWarning( - "Failed to set scene collection to '{TargetName}': Not found or invalid.", - targetSceneCollectionName - ); + client._logger.LogSceneCollectionNotFound(targetSceneCollectionName); return false; // Switch failed because target doesn't exist } // Let other exceptions propagate @@ -230,10 +227,7 @@ is RequestStatusCode.ResourceNotFound or RequestStatusCode.InvalidRequestField ) { - client._logger.LogWarning( - "Failed to set profile to '{TargetName}': Not found or invalid.", - targetProfileName - ); + client._logger.LogProfileNotFound(targetProfileName); return false; // Switch failed because target doesn't exist } // Let other exceptions propagate diff --git a/ObsWebSocket.Core/Groups/FiltersGroup.cs b/ObsWebSocket.Core/Groups/FiltersGroup.cs index e164b64..db46733 100644 --- a/ObsWebSocket.Core/Groups/FiltersGroup.cs +++ b/ObsWebSocket.Core/Groups/FiltersGroup.cs @@ -71,9 +71,8 @@ public readonly partial struct FiltersGroup } catch (JsonException jsonEx) { - client._logger.LogError( + client._logger.LogFilterSettingsUnreadable( jsonEx, - "Failed to deserialize filter settings for '{FilterName}' on '{SourceName}' to type {TypeName}.", filterName, sourceName, typeof(T).Name diff --git a/ObsWebSocket.Core/Groups/InputsGroup.cs b/ObsWebSocket.Core/Groups/InputsGroup.cs index 5307ff0..b5481ca 100644 --- a/ObsWebSocket.Core/Groups/InputsGroup.cs +++ b/ObsWebSocket.Core/Groups/InputsGroup.cs @@ -80,7 +80,7 @@ public async Task SetInputMutesAsync( if (names.Count == 0) { - client._logger.LogDebug("SetInputMutesAsync called with an empty list, nothing to do."); + client._logger.LogNoInputMutesToSet(); return new BatchResults([]); } @@ -99,8 +99,7 @@ public async Task SetInputMutesAsync( RequestResponsePayload result = results[i]; if (!result.RequestStatus.Result) { - client._logger.LogWarning( - "Failed to set mute state for input '{InputName}': code {Code}, {Comment}", + client._logger.LogInputMuteRejected( names[i], result.RequestStatus.Code, result.RequestStatus.Comment ?? "no comment" @@ -160,12 +159,7 @@ public async Task SetInputMutesAsync( } catch (JsonException jsonEx) { - client._logger.LogError( - jsonEx, - "Failed to deserialize input settings for '{InputName}' to type {TypeName}.", - inputName, - typeof(T).Name - ); + client._logger.LogInputSettingsUnreadable(jsonEx, inputName, typeof(T).Name); return null; } } diff --git a/ObsWebSocket.Core/Groups/OutputsGroup.cs b/ObsWebSocket.Core/Groups/OutputsGroup.cs index 21fcce1..0fd8396 100644 --- a/ObsWebSocket.Core/Groups/OutputsGroup.cs +++ b/ObsWebSocket.Core/Groups/OutputsGroup.cs @@ -193,7 +193,7 @@ public async Task IsVirtualCamActiveAsync(CancellationToken cancellationTo VirtualcamStateChangedEventArgs ev = await waitTask.ConfigureAwait(false); return ev.EventData.OutputActive; } - catch (TimeoutException) + catch (ObsWebSocketTimeoutException) { return null; } diff --git a/ObsWebSocket.Core/Groups/RecordGroup.cs b/ObsWebSocket.Core/Groups/RecordGroup.cs index 5ca831a..b5b9214 100644 --- a/ObsWebSocket.Core/Groups/RecordGroup.cs +++ b/ObsWebSocket.Core/Groups/RecordGroup.cs @@ -61,7 +61,7 @@ public readonly partial struct RecordGroup RecordStateChangedEventArgs ev = await waitTask.ConfigureAwait(false); return ev.EventData.OutputState; } - catch (TimeoutException) + catch (ObsWebSocketTimeoutException) { return null; } diff --git a/ObsWebSocket.Core/Groups/ScenesGroup.cs b/ObsWebSocket.Core/Groups/ScenesGroup.cs index 8d370b6..54e84b9 100644 --- a/ObsWebSocket.Core/Groups/ScenesGroup.cs +++ b/ObsWebSocket.Core/Groups/ScenesGroup.cs @@ -104,7 +104,7 @@ await client /// Optional: Maximum time to wait for the completion event after triggering the switch. Defaults based on client configuration. /// A token to cancel the operation. /// Thrown if OBS fails to perform the switch or if the underlying wait fails unexpectedly. - /// Thrown if the expected event confirming the switch completion is not received within the timeout period. + /// Thrown if the expected event confirming the switch completion is not received within the timeout period. /// Thrown if the client is not connected, or if trying to switch Preview scene when Studio Mode is disabled. /// Thrown if the operation is canceled via the cancellationToken. private async Task SwitchSceneAndWaitCoreAsync( @@ -119,6 +119,19 @@ private async Task SwitchSceneAndWaitCoreAsync( ArgumentException.ThrowIfNullOrEmpty(sceneName); client.EnsureConnected(); // Ensure client is connected + // OBS raises nothing when the scene is already the one asked for, so waiting for the + // event would sit here until it timed out on a switch that had nothing to do. + GetSceneListResponseData current = await client + .Scenes.GetSceneListAsync(new GetSceneListRequestData(), cancellationToken) + .ConfigureAwait(false); + string? active = switchToProgram + ? current.CurrentProgramSceneName + : current.CurrentPreviewSceneName; + if (string.Equals(active, sceneName, StringComparison.Ordinal)) + { + return; + } + // Determine default timeout if not provided int baseWaitMs = transitionDurationMs.HasValue && transitionDurationMs > 0 && switchToProgram @@ -126,33 +139,21 @@ private async Task SwitchSceneAndWaitCoreAsync( : client._options.Value.RequestTimeoutMs + 2000; // Or default request timeout + buffer TimeSpan effectiveTimeout = timeout ?? TimeSpan.FromMilliseconds(baseWaitMs); - // --- Corrected Event Waiting Setup --- - // We need separate task variables because Task is not covariant. - Task? programWaitTask = null; - Task? previewWaitTask = null; - string eventDescription; - - if (switchToProgram) - { - eventDescription = $"CurrentProgramSceneChanged to '{sceneName}'"; - // Start the wait BEFORE triggering the action. - programWaitTask = client.WaitForEventAsync( + // Started before the switch is sent, so a fast confirmation cannot be missed. + string eventDescription = switchToProgram + ? $"CurrentProgramSceneChanged to '{sceneName}'" + : $"CurrentPreviewSceneChanged to '{sceneName}'"; + Task confirmation = switchToProgram + ? client.WaitForEventAsync( predicate: args => args.EventData.SceneName == sceneName, timeout: effectiveTimeout, cancellationToken: cancellationToken - ); - } - else - { - eventDescription = $"CurrentPreviewSceneChanged to '{sceneName}'"; - // Start the wait BEFORE triggering the action. - previewWaitTask = client.WaitForEventAsync( + ) + : client.WaitForEventAsync( predicate: args => args.EventData.SceneName == sceneName, timeout: effectiveTimeout, cancellationToken: cancellationToken ); - } - // --------------------------------------- try { @@ -167,45 +168,20 @@ await SwitchSceneCoreAsync( ) .ConfigureAwait(false); - client._logger.LogDebug( - "Switch triggered for '{SceneName}', waiting for {EventDescription}...", - sceneName, - eventDescription - ); + client._logger.LogSceneSwitchSent(sceneName, eventDescription); - if (programWaitTask is not null) - { - _ = await programWaitTask.ConfigureAwait(false); - } - else if (previewWaitTask is not null) - { - _ = await previewWaitTask.ConfigureAwait(false); - } - else - { - throw new InvalidOperationException("Internal error: No wait task was assigned."); - } + await confirmation.ConfigureAwait(false); - client._logger.LogInformation( - "Successfully switched and confirmed {EventDescription}.", - eventDescription - ); + client._logger.LogSceneSwitchConfirmed(eventDescription); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { - client._logger.LogInformation( - "SwitchSceneAndWaitAsync operation was canceled externally for scene '{SceneName}'.", - sceneName - ); + client._logger.LogSceneSwitchCancelled(sceneName); throw; // Re-throw cancellation } catch (Exception ex) { - client._logger.LogError( - ex, - "Error during SwitchSceneAndWaitAsync for scene '{SceneName}'.", - sceneName - ); + client._logger.LogSceneSwitchFailed(ex, sceneName); throw; } // The finally block within WaitForEventAsync handles unsubscribing the temporary event handler. @@ -271,7 +247,7 @@ public Task SwitchPreviewSceneAsync( /// The scene to switch to. /// How long to wait for confirmation. /// A token to cancel the operation. - /// Thrown if the confirmation does not arrive in time. + /// Thrown if the confirmation does not arrive in time. public Task SwitchProgramSceneAndWaitAsync( string sceneName, TimeSpan? timeout = null, diff --git a/ObsWebSocket.Core/Groups/SourcesGroup.cs b/ObsWebSocket.Core/Groups/SourcesGroup.cs index 76b3586..7548f16 100644 --- a/ObsWebSocket.Core/Groups/SourcesGroup.cs +++ b/ObsWebSocket.Core/Groups/SourcesGroup.cs @@ -25,7 +25,10 @@ public readonly partial struct SourcesGroup /// The name of the input or scene to check. /// A token to cancel the operation. /// True if a source (input or scene) with the specified name exists, false otherwise. - /// Thrown if an unexpected error occurs during API calls. + /// + /// Thrown if the lookup could not be completed, for example because the connection dropped. + /// A request OBS refuses is reported as the source not existing. + /// /// Thrown if the client is not connected. public async Task SourceExistsAsync( string sourceName, @@ -64,14 +67,11 @@ await client ) ?? false; } - catch (ObsWebSocketException ex) + catch (ObsWebSocketRequestException ex) { - // Log the specific OBS error but return false as the source effectively doesn't exist or couldn't be verified - client._logger.LogWarning( - ex, - "OBS error while checking if source '{SourceName}' exists. Assuming it doesn't.", - sourceName - ); + // Only OBS refusing the lookup means "not there". A lost connection or a timeout + // propagates, because reporting it as absent invites the caller to create a duplicate. + client._logger.LogSourceLookupRefused(ex, sourceName); return false; } // Let other exceptions (like InvalidOperationException for disconnect) propagate @@ -121,20 +121,14 @@ await client catch (ObsWebSocketRequestException ex) when (ex.StatusCode is RequestStatusCode.ResourceNotFound) { - client._logger.LogWarning( - "Source '{SourceName}' not found for screenshot.", - sourceName - ); + client._logger.LogScreenshotSourceNotFound(sourceName); return null; } // Let other exceptions propagate if (string.IsNullOrEmpty(response?.ImageData)) { - client._logger.LogWarning( - "Received null or empty image data for screenshot of '{SourceName}'.", - sourceName - ); + client._logger.LogScreenshotEmpty(sourceName); return null; } @@ -144,11 +138,7 @@ await client } catch (FormatException formatEx) { - client._logger.LogError( - formatEx, - "Failed to decode Base64 image data for screenshot of '{SourceName}'.", - sourceName - ); + client._logger.LogScreenshotUndecodable(formatEx, sourceName); return null; } } diff --git a/ObsWebSocket.Core/Groups/StreamGroup.cs b/ObsWebSocket.Core/Groups/StreamGroup.cs index f3f5eb3..7406922 100644 --- a/ObsWebSocket.Core/Groups/StreamGroup.cs +++ b/ObsWebSocket.Core/Groups/StreamGroup.cs @@ -60,7 +60,7 @@ public readonly partial struct StreamGroup StreamStateChangedEventArgs ev = await waitTask.ConfigureAwait(false); return ev.EventData.OutputState; } - catch (TimeoutException) + catch (ObsWebSocketTimeoutException) { return null; } diff --git a/ObsWebSocket.Core/Networking/IWebSocketConnection .cs b/ObsWebSocket.Core/Networking/IWebSocketConnection.cs similarity index 100% rename from ObsWebSocket.Core/Networking/IWebSocketConnection .cs rename to ObsWebSocket.Core/Networking/IWebSocketConnection.cs diff --git a/ObsWebSocket.Core/ObsWebSocketClient.cs b/ObsWebSocket.Core/ObsWebSocketClient.cs index 84a8afe..d01bc1e 100644 --- a/ObsWebSocket.Core/ObsWebSocketClient.cs +++ b/ObsWebSocket.Core/ObsWebSocketClient.cs @@ -1027,10 +1027,23 @@ CancellationToken externalCancellationToken ); CancellationToken loopToken = linkedLoopCts.Token; + bool connectionWasLost = false; + try { while (!loopToken.IsCancellationRequested) { + // A live connection resets the attempt count, so the retry gate below never sees + // the first attempt after a drop. Without this, a client told not to reconnect + // would reconnect once after every lost connection. + if (connectionWasLost && !settings.AutoReconnectEnabled) + { + _logger.LogMaxReconnectAttemptsReachedOrAutoReconnect( + settings.MaxReconnectAttempts + ); + break; + } + attempt++; bool isConnectedThisAttempt = false; Exception? attemptException = null; @@ -1186,6 +1199,7 @@ CancellationToken externalCancellationToken _completionException = attemptException; } + connectionWasLost = isConnectedThisAttempt; bool cleanupNeeded = !isConnectedThisAttempt || (isConnectedThisAttempt && attemptException != null); @@ -1426,14 +1440,6 @@ or OperationCanceledException throw; } - if ( - ex is ObsWebSocketException obsEx - && obsEx.Message.Contains("Authentication", StringComparison.OrdinalIgnoreCase) - ) - { - throw new AuthenticationFailureException(ex.Message, ex); - } - throw new ConnectionAttemptFailedException( $"Connection attempt {attempt} failed during handshake: {ex.Message}", ex @@ -1579,9 +1585,15 @@ private void HandleServerClose(ObsConnectionContext connection) _logger.LogServerInitiatedUnexpectedCloseStatusDesc(status, desc); } - ObsWebSocketException closeEx = new( - $"Connection closed by server. Status: {status}, Description: {desc}" - ); + string closeMessage = $"Connection closed by server. Status: {status}, Description: {desc}"; + + // OBS closes with 4009 when the identify payload does not authenticate. That is fatal: + // retrying the same credentials only repeats it, so it has to be distinguishable from a + // connection that merely dropped. + ObsWebSocketException closeEx = + (int?)status == (int)WebSocketCloseCode.AuthenticationFailed + ? new AuthenticationFailureException(closeMessage) + : new ObsWebSocketException(closeMessage); CleanupConnectionOnly(closeEx); if (ws.State == WebSocketState.CloseReceived) @@ -1993,459 +2005,20 @@ Action invoker } } + /// + /// The generated dispatch table, with the one event whose payload the protocol does not + /// describe replaced by its own handler. + /// private static Dictionary< string, Action - > InitializeEventHandlers() => - new(StringComparer.Ordinal) - { - // Config - ["CurrentSceneCollectionChanging"] = (c, s, d) => - c.TryHandleEvent< - CurrentSceneCollectionChangingPayload, - CurrentSceneCollectionChangingEventArgs - >( - s, - "CurrentSceneCollectionChanging", - d, - p => new(p), - c.OnCurrentSceneCollectionChanging - ), - ["CurrentSceneCollectionChanged"] = (c, s, d) => - c.TryHandleEvent< - CurrentSceneCollectionChangedPayload, - CurrentSceneCollectionChangedEventArgs - >( - s, - "CurrentSceneCollectionChanged", - d, - p => new(p), - c.OnCurrentSceneCollectionChanged - ), - ["SceneCollectionListChanged"] = (c, s, d) => - c.TryHandleEvent< - SceneCollectionListChangedPayload, - SceneCollectionListChangedEventArgs - >(s, "SceneCollectionListChanged", d, p => new(p), c.OnSceneCollectionListChanged), - ["CurrentProfileChanging"] = (c, s, d) => - c.TryHandleEvent( - s, - "CurrentProfileChanging", - d, - p => new(p), - c.OnCurrentProfileChanging - ), - ["CurrentProfileChanged"] = (c, s, d) => - c.TryHandleEvent( - s, - "CurrentProfileChanged", - d, - p => new(p), - c.OnCurrentProfileChanged - ), - ["ProfileListChanged"] = (c, s, d) => - c.TryHandleEvent( - s, - "ProfileListChanged", - d, - p => new(p), - c.OnProfileListChanged - ), - // Filters - ["SourceFilterListReindexed"] = (c, s, d) => - c.TryHandleEvent< - SourceFilterListReindexedPayload, - SourceFilterListReindexedEventArgs - >(s, "SourceFilterListReindexed", d, p => new(p), c.OnSourceFilterListReindexed), - ["SourceFilterCreated"] = (c, s, d) => - c.TryHandleEvent( - s, - "SourceFilterCreated", - d, - p => new(p), - c.OnSourceFilterCreated - ), - ["SourceFilterRemoved"] = (c, s, d) => - c.TryHandleEvent( - s, - "SourceFilterRemoved", - d, - p => new(p), - c.OnSourceFilterRemoved - ), - ["SourceFilterNameChanged"] = (c, s, d) => - c.TryHandleEvent( - s, - "SourceFilterNameChanged", - d, - p => new(p), - c.OnSourceFilterNameChanged - ), - ["SourceFilterSettingsChanged"] = (c, s, d) => - c.TryHandleEvent< - SourceFilterSettingsChangedPayload, - SourceFilterSettingsChangedEventArgs - >( - s, - "SourceFilterSettingsChanged", - d, - p => new(p), - c.OnSourceFilterSettingsChanged - ), - ["SourceFilterEnableStateChanged"] = (c, s, d) => - c.TryHandleEvent< - SourceFilterEnableStateChangedPayload, - SourceFilterEnableStateChangedEventArgs - >( - s, - "SourceFilterEnableStateChanged", - d, - p => new(p), - c.OnSourceFilterEnableStateChanged - ), - // General - ["ExitStarted"] = (c, _, d) => c.OnExitStarted(new ExitStartedEventArgs()), - ["VendorEvent"] = (c, s, d) => - c.TryHandleEvent( - s, - "VendorEvent", - d, - p => new(p), - c.OnVendorEvent - ), - ["CustomEvent"] = (c, s, d) => c.HandleCustomEvent(s, d), - // Inputs - ["InputCreated"] = (c, s, d) => - c.TryHandleEvent( - s, - "InputCreated", - d, - p => new(p), - c.OnInputCreated - ), - ["InputRemoved"] = (c, s, d) => - c.TryHandleEvent( - s, - "InputRemoved", - d, - p => new(p), - c.OnInputRemoved - ), - ["InputNameChanged"] = (c, s, d) => - c.TryHandleEvent( - s, - "InputNameChanged", - d, - p => new(p), - c.OnInputNameChanged - ), - ["InputSettingsChanged"] = (c, s, d) => - c.TryHandleEvent( - s, - "InputSettingsChanged", - d, - p => new(p), - c.OnInputSettingsChanged - ), - ["InputActiveStateChanged"] = (c, s, d) => - c.TryHandleEvent( - s, - "InputActiveStateChanged", - d, - p => new(p), - c.OnInputActiveStateChanged - ), - ["InputShowStateChanged"] = (c, s, d) => - c.TryHandleEvent( - s, - "InputShowStateChanged", - d, - p => new(p), - c.OnInputShowStateChanged - ), - ["InputMuteStateChanged"] = (c, s, d) => - c.TryHandleEvent( - s, - "InputMuteStateChanged", - d, - p => new(p), - c.OnInputMuteStateChanged - ), - ["InputVolumeChanged"] = (c, s, d) => - c.TryHandleEvent( - s, - "InputVolumeChanged", - d, - p => new(p), - c.OnInputVolumeChanged - ), - ["InputAudioBalanceChanged"] = (c, s, d) => - c.TryHandleEvent< - InputAudioBalanceChangedPayload, - InputAudioBalanceChangedEventArgs - >(s, "InputAudioBalanceChanged", d, p => new(p), c.OnInputAudioBalanceChanged), - ["InputAudioSyncOffsetChanged"] = (c, s, d) => - c.TryHandleEvent< - InputAudioSyncOffsetChangedPayload, - InputAudioSyncOffsetChangedEventArgs - >( - s, - "InputAudioSyncOffsetChanged", - d, - p => new(p), - c.OnInputAudioSyncOffsetChanged - ), - ["InputAudioTracksChanged"] = (c, s, d) => - c.TryHandleEvent( - s, - "InputAudioTracksChanged", - d, - p => new(p), - c.OnInputAudioTracksChanged - ), - ["InputAudioMonitorTypeChanged"] = (c, s, d) => - c.TryHandleEvent< - InputAudioMonitorTypeChangedPayload, - InputAudioMonitorTypeChangedEventArgs - >( - s, - "InputAudioMonitorTypeChanged", - d, - p => new(p), - c.OnInputAudioMonitorTypeChanged - ), - ["InputVolumeMeters"] = (c, s, d) => - c.TryHandleEvent( - s, - "InputVolumeMeters", - d, - p => new(p), - c.OnInputVolumeMeters - ), - // Media Inputs - ["MediaInputPlaybackStarted"] = (c, s, d) => - c.TryHandleEvent< - MediaInputPlaybackStartedPayload, - MediaInputPlaybackStartedEventArgs - >(s, "MediaInputPlaybackStarted", d, p => new(p), c.OnMediaInputPlaybackStarted), - ["MediaInputPlaybackEnded"] = (c, s, d) => - c.TryHandleEvent( - s, - "MediaInputPlaybackEnded", - d, - p => new(p), - c.OnMediaInputPlaybackEnded - ), - ["MediaInputActionTriggered"] = (c, s, d) => - c.TryHandleEvent< - MediaInputActionTriggeredPayload, - MediaInputActionTriggeredEventArgs - >(s, "MediaInputActionTriggered", d, p => new(p), c.OnMediaInputActionTriggered), - // Outputs - ["StreamStateChanged"] = (c, s, d) => - c.TryHandleEvent( - s, - "StreamStateChanged", - d, - p => new(p), - c.OnStreamStateChanged - ), - ["RecordStateChanged"] = (c, s, d) => - c.TryHandleEvent( - s, - "RecordStateChanged", - d, - p => new(p), - c.OnRecordStateChanged - ), - ["RecordFileChanged"] = (c, s, d) => - c.TryHandleEvent( - s, - "RecordFileChanged", - d, - p => new(p), - c.OnRecordFileChanged - ), - ["ReplayBufferStateChanged"] = (c, s, d) => - c.TryHandleEvent< - ReplayBufferStateChangedPayload, - ReplayBufferStateChangedEventArgs - >(s, "ReplayBufferStateChanged", d, p => new(p), c.OnReplayBufferStateChanged), - ["VirtualcamStateChanged"] = (c, s, d) => - c.TryHandleEvent( - s, - "VirtualcamStateChanged", - d, - p => new(p), - c.OnVirtualcamStateChanged - ), - ["ReplayBufferSaved"] = (c, s, d) => - c.TryHandleEvent( - s, - "ReplayBufferSaved", - d, - p => new(p), - c.OnReplayBufferSaved - ), - // Scene Items - ["SceneItemCreated"] = (c, s, d) => - c.TryHandleEvent( - s, - "SceneItemCreated", - d, - p => new(p), - c.OnSceneItemCreated - ), - ["SceneItemRemoved"] = (c, s, d) => - c.TryHandleEvent( - s, - "SceneItemRemoved", - d, - p => new(p), - c.OnSceneItemRemoved - ), - ["SceneItemListReindexed"] = (c, s, d) => - c.TryHandleEvent( - s, - "SceneItemListReindexed", - d, - p => new(p), - c.OnSceneItemListReindexed - ), - ["SceneItemEnableStateChanged"] = (c, s, d) => - c.TryHandleEvent< - SceneItemEnableStateChangedPayload, - SceneItemEnableStateChangedEventArgs - >( - s, - "SceneItemEnableStateChanged", - d, - p => new(p), - c.OnSceneItemEnableStateChanged - ), - ["SceneItemLockStateChanged"] = (c, s, d) => - c.TryHandleEvent< - SceneItemLockStateChangedPayload, - SceneItemLockStateChangedEventArgs - >(s, "SceneItemLockStateChanged", d, p => new(p), c.OnSceneItemLockStateChanged), - ["SceneItemSelected"] = (c, s, d) => - c.TryHandleEvent( - s, - "SceneItemSelected", - d, - p => new(p), - c.OnSceneItemSelected - ), - ["SceneItemTransformChanged"] = (c, s, d) => - c.TryHandleEvent< - SceneItemTransformChangedPayload, - SceneItemTransformChangedEventArgs - >(s, "SceneItemTransformChanged", d, p => new(p), c.OnSceneItemTransformChanged), - // Scenes - ["SceneCreated"] = (c, s, d) => - c.TryHandleEvent( - s, - "SceneCreated", - d, - p => new(p), - c.OnSceneCreated - ), - ["SceneRemoved"] = (c, s, d) => - c.TryHandleEvent( - s, - "SceneRemoved", - d, - p => new(p), - c.OnSceneRemoved - ), - ["SceneNameChanged"] = (c, s, d) => - c.TryHandleEvent( - s, - "SceneNameChanged", - d, - p => new(p), - c.OnSceneNameChanged - ), - ["CurrentProgramSceneChanged"] = (c, s, d) => - c.TryHandleEvent< - CurrentProgramSceneChangedPayload, - CurrentProgramSceneChangedEventArgs - >(s, "CurrentProgramSceneChanged", d, p => new(p), c.OnCurrentProgramSceneChanged), - ["CurrentPreviewSceneChanged"] = (c, s, d) => - c.TryHandleEvent< - CurrentPreviewSceneChangedPayload, - CurrentPreviewSceneChangedEventArgs - >(s, "CurrentPreviewSceneChanged", d, p => new(p), c.OnCurrentPreviewSceneChanged), - ["SceneListChanged"] = (c, s, d) => - c.TryHandleEvent( - s, - "SceneListChanged", - d, - p => new(p), - c.OnSceneListChanged - ), - // Transitions - ["CurrentSceneTransitionChanged"] = (c, s, d) => - c.TryHandleEvent< - CurrentSceneTransitionChangedPayload, - CurrentSceneTransitionChangedEventArgs - >( - s, - "CurrentSceneTransitionChanged", - d, - p => new(p), - c.OnCurrentSceneTransitionChanged - ), - ["CurrentSceneTransitionDurationChanged"] = (c, s, d) => - c.TryHandleEvent< - CurrentSceneTransitionDurationChangedPayload, - CurrentSceneTransitionDurationChangedEventArgs - >( - s, - "CurrentSceneTransitionDurationChanged", - d, - p => new(p), - c.OnCurrentSceneTransitionDurationChanged - ), - ["SceneTransitionStarted"] = (c, s, d) => - c.TryHandleEvent( - s, - "SceneTransitionStarted", - d, - p => new(p), - c.OnSceneTransitionStarted - ), - ["SceneTransitionEnded"] = (c, s, d) => - c.TryHandleEvent( - s, - "SceneTransitionEnded", - d, - p => new(p), - c.OnSceneTransitionEnded - ), - ["SceneTransitionVideoEnded"] = (c, s, d) => - c.TryHandleEvent< - SceneTransitionVideoEndedPayload, - SceneTransitionVideoEndedEventArgs - >(s, "SceneTransitionVideoEnded", d, p => new(p), c.OnSceneTransitionVideoEnded), - // UI - ["StudioModeStateChanged"] = (c, s, d) => - c.TryHandleEvent( - s, - "StudioModeStateChanged", - d, - p => new(p), - c.OnStudioModeStateChanged - ), - ["ScreenshotSaved"] = (c, s, d) => - c.TryHandleEvent( - s, - "ScreenshotSaved", - d, - p => new(p), - c.OnScreenshotSaved - ), - }; + > InitializeEventHandlers() + { + Dictionary> table = + CreateEventDispatchTable(); + table["CustomEvent"] = static (c, s, d) => c.HandleCustomEvent(s, d); + return table; + } #endregion #region Helper Methods (Static & Instance) @@ -2574,40 +2147,23 @@ string requestId } } - private void LogEventDataDeserializationError( - string description, - object? data, - Exception? ex = null - ) + /// Logs a payload that could not be read, keeping only the start of large ones. + private void LogEventDataDeserializationError(string description, object? data) { - string rawDataStr = data is JsonElement je - ? ( - je.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined - ? "[Null/Undefined]" - : je.GetRawText() - ) - : (data?.GetType().Name ?? "[Null]"); - const string logMessage = - "Failed to deserialize {Description}. Type: {DataType}, Raw: {RawData}"; - if (ex != null) + string raw = data switch { - _logger.LogError( - ex, - logMessage, - description, - data?.GetType().Name ?? "null", - rawDataStr.Length > 512 ? rawDataStr[..512] + "..." : rawDataStr - ); - } - else - { - _logger.LogWarning( - logMessage, - description, - data?.GetType().Name ?? "null", - rawDataStr.Length > 512 ? rawDataStr[..512] + "..." : rawDataStr - ); - } + JsonElement { ValueKind: JsonValueKind.Null or JsonValueKind.Undefined } => + "[Null/Undefined]", + JsonElement element => element.GetRawText(), + null => "[Null]", + _ => data.GetType().Name, + }; + + _logger.LogPayloadUnreadable( + description, + data?.GetType().Name ?? "null", + raw.Length > 512 ? string.Concat(raw.AsSpan(0, 512), "...") : raw + ); } private static async Task WaitForHandshakeMessageAsync( @@ -2632,6 +2188,21 @@ CancellationToken attemptCancellationToken { return await tcs.Task.WaitAsync(linkedWaitCts.Token).ConfigureAwait(false); } + catch (OperationCanceledException) when (tcs.Task.IsFaulted) + { + // Same race as a request: the handshake fault, such as a rejected password, arrives + // after the cancellation of the attempt it ended. + Exception cause = tcs.Task.Exception!.InnerException!; + if (cause is AuthenticationFailureException) + { + throw cause; + } + + throw new ConnectionAttemptFailedException( + $"Failed waiting for {messageName}, possibly due to prior error set on TCS.", + cause + ); + } catch (OperationCanceledException ex) when (timeoutCts.IsCancellationRequested) { throw new ConnectionAttemptFailedException( @@ -2639,6 +2210,11 @@ CancellationToken attemptCancellationToken ex ); } + catch (AuthenticationFailureException) + { + // Fatal, so it travels as itself rather than as one more failed attempt. + throw; + } catch (Exception ex) when (ex is not OperationCanceledException) { throw new ConnectionAttemptFailedException( @@ -2727,6 +2303,16 @@ CancellationToken linkedRequestAndLifetimeToken { return await tcs.Task.WaitAsync(linkedWaitCts.Token).ConfigureAwait(false); } + catch (OperationCanceledException) when (tcs.Task.IsFaulted) + { + // A lost connection faults the request and then cancels the client lifetime. The + // fault reaches this wait a thread-pool turn later than the cancellation does, and it + // is the one that says what happened. + throw new ObsWebSocketException( + $"{requestDescription} failed while waiting for response.", + tcs.Task.Exception!.InnerException + ); + } catch (OperationCanceledException ex) when (timeoutCts.IsCancellationRequested) { throw new ObsWebSocketTimeoutException( diff --git a/ObsWebSocket.Core/ObsWebSocketClientLog.cs b/ObsWebSocket.Core/ObsWebSocketClientLog.cs index e7a3cee..7079902 100644 --- a/ObsWebSocket.Core/ObsWebSocketClientLog.cs +++ b/ObsWebSocket.Core/ObsWebSocketClientLog.cs @@ -921,4 +921,173 @@ public static partial void LogExceptionInUserProvidedAuthenticationfailureEventH this ILogger logger, Exception exception ); + + [LoggerMessage( + EventId = 101, + Level = LogLevel.Warning, + Message = "Could not switch to scene collection '{TargetName}': OBS does not know it." + )] + public static partial void LogSceneCollectionNotFound(this ILogger logger, string targetName); + + [LoggerMessage( + EventId = 102, + Level = LogLevel.Warning, + Message = "Could not switch to profile '{TargetName}': OBS does not know it." + )] + public static partial void LogProfileNotFound(this ILogger logger, string targetName); + + [LoggerMessage( + EventId = 103, + Level = LogLevel.Error, + Message = "Could not read the settings of filter '{FilterName}' on '{SourceName}' as {TypeName}." + )] + public static partial void LogFilterSettingsUnreadable( + this ILogger logger, + Exception exception, + string filterName, + string sourceName, + string typeName + ); + + [LoggerMessage( + EventId = 104, + Level = LogLevel.Debug, + Message = "SetInputMutesAsync was given no inputs, so nothing was sent." + )] + public static partial void LogNoInputMutesToSet(this ILogger logger); + + [LoggerMessage( + EventId = 105, + Level = LogLevel.Warning, + Message = "OBS rejected the mute state for input '{InputName}': code {Code}, {Comment}" + )] + public static partial void LogInputMuteRejected( + this ILogger logger, + string inputName, + int code, + string comment + ); + + [LoggerMessage( + EventId = 106, + Level = LogLevel.Error, + Message = "Could not read the settings of input '{InputName}' as {TypeName}." + )] + public static partial void LogInputSettingsUnreadable( + this ILogger logger, + Exception exception, + string inputName, + string typeName + ); + + [LoggerMessage( + EventId = 107, + Level = LogLevel.Debug, + Message = "Switch to '{SceneName}' sent, waiting for {EventDescription}." + )] + public static partial void LogSceneSwitchSent( + this ILogger logger, + string sceneName, + string eventDescription + ); + + [LoggerMessage( + EventId = 108, + Level = LogLevel.Information, + Message = "Switch confirmed by {EventDescription}." + )] + public static partial void LogSceneSwitchConfirmed( + this ILogger logger, + string eventDescription + ); + + [LoggerMessage( + EventId = 109, + Level = LogLevel.Information, + Message = "Waiting for the switch to '{SceneName}' was cancelled." + )] + public static partial void LogSceneSwitchCancelled(this ILogger logger, string sceneName); + + [LoggerMessage( + EventId = 110, + Level = LogLevel.Error, + Message = "Switching to '{SceneName}' and waiting for confirmation failed." + )] + public static partial void LogSceneSwitchFailed( + this ILogger logger, + Exception exception, + string sceneName + ); + + [LoggerMessage( + EventId = 111, + Level = LogLevel.Warning, + Message = "OBS refused the lookup for source '{SourceName}', so it is reported as absent." + )] + public static partial void LogSourceLookupRefused( + this ILogger logger, + Exception exception, + string sourceName + ); + + [LoggerMessage( + EventId = 112, + Level = LogLevel.Warning, + Message = "No source named '{SourceName}' to take a screenshot of." + )] + public static partial void LogScreenshotSourceNotFound(this ILogger logger, string sourceName); + + [LoggerMessage( + EventId = 113, + Level = LogLevel.Warning, + Message = "OBS returned no image data for the screenshot of '{SourceName}'." + )] + public static partial void LogScreenshotEmpty(this ILogger logger, string sourceName); + + [LoggerMessage( + EventId = 114, + Level = LogLevel.Error, + Message = "The screenshot of '{SourceName}' was not valid Base64." + )] + public static partial void LogScreenshotUndecodable( + this ILogger logger, + Exception exception, + string sourceName + ); + + [LoggerMessage( + EventId = 115, + Level = LogLevel.Warning, + Message = "Could not reach OBS during startup. The client will keep trying." + )] + public static partial void LogObsUnreachableAtStartup(this ILogger logger, Exception exception); + + [LoggerMessage( + EventId = 116, + Level = LogLevel.Information, + Message = "OBS connection settings changed, reconnecting to {ServerUri}." + )] + public static partial void LogConnectionSettingsChanged(this ILogger logger, Uri serverUri); + + [LoggerMessage( + EventId = 117, + Level = LogLevel.Warning, + Message = "Reconnect after a settings change did not succeed." + )] + public static partial void LogReconnectAfterSettingsChangeFailed( + this ILogger logger, + Exception exception + ); + + [LoggerMessage( + EventId = 118, + Level = LogLevel.Warning, + Message = "Could not read {Description}. Type: {DataType}, Raw: {RawData}" + )] + public static partial void LogPayloadUnreadable( + this ILogger logger, + string description, + string dataType, + string rawData + ); } diff --git a/ObsWebSocket.Core/ObsWebSocketHosting.cs b/ObsWebSocket.Core/ObsWebSocketHosting.cs index 195b955..df66bee 100644 --- a/ObsWebSocket.Core/ObsWebSocketHosting.cs +++ b/ObsWebSocket.Core/ObsWebSocketHosting.cs @@ -79,10 +79,7 @@ private async Task ConnectAsync(CancellationToken cancellationToken) { // A streaming tool should still start when OBS is not running yet; the client's own // reconnect handles it from here. - logger.LogWarning( - ex, - "Could not reach OBS during startup. The client will keep trying." - ); + logger.LogObsUnreachableAtStartup(ex); } } @@ -94,10 +91,7 @@ private void OnOptionsChanged(ObsWebSocketClientOptions updated) return; } - logger.LogInformation( - "OBS connection settings changed, reconnecting to {ServerUri}.", - updated.ServerUri - ); + logger.LogConnectionSettingsChanged(updated.ServerUri); _ = _pending.Writer.TryWrite(ObsConnectionSettings.Capture(updated)); } @@ -123,7 +117,7 @@ ObsConnectionSettings _ in _pending catch (Exception ex) when (ex is ObsWebSocketException or OperationCanceledException) { - logger.LogWarning(ex, "Reconnect after a settings change did not succeed."); + logger.LogReconnectAfterSettingsChangeFailed(ex); } } } diff --git a/ObsWebSocket.Core/Protocol/Common/StubTypes.cs b/ObsWebSocket.Core/Protocol/Common/StubTypes.cs index dd441b2..108061a 100644 --- a/ObsWebSocket.Core/Protocol/Common/StubTypes.cs +++ b/ObsWebSocket.Core/Protocol/Common/StubTypes.cs @@ -631,7 +631,7 @@ public sealed class SceneItemStub /// Transform data for the scene item. [JsonPropertyName("sceneItemTransform")] [Key("sceneItemTransform")] - public required SceneItemTransformStub SceneItemTransform { get; init; } // Made nullable for safety + public required SceneItemTransformStub SceneItemTransform { get; init; } /// Captures any extra fields not explicitly defined in the stub. [IgnoreMember] diff --git a/ObsWebSocket.Example/Program.cs b/ObsWebSocket.Example/Program.cs index c3d84ab..70783e2 100644 --- a/ObsWebSocket.Example/Program.cs +++ b/ObsWebSocket.Example/Program.cs @@ -26,10 +26,6 @@ builder.Logging.AddConfiguration(builder.Configuration.GetSection("Logging")); builder.Logging.AddConsole(); -// Watches for payloads the client could not read, so validation fails on a shape mismatch instead -// of leaving it in the log for someone to notice. -builder.Logging.AddProvider(new SerializationFailureSink()); - // Configure OBS WebSocket Client options from "Obs" section in appsettings.json builder.Services.Configure(builder.Configuration.GetSection("Obs")); builder.Services.Configure( diff --git a/ObsWebSocket.Example/Worker.cs b/ObsWebSocket.Example/Worker.cs index 784d4d4..5fb989f 100644 --- a/ObsWebSocket.Example/Worker.cs +++ b/ObsWebSocket.Example/Worker.cs @@ -32,7 +32,6 @@ internal sealed partial class Worker( ExampleStartupCommandOptions startupCommandOptions, ILoggerFactory loggerFactory, IWebSocketConnectionFactory connectionFactory, - HealthCheckService healthChecks, IHostApplicationLifetime lifetime ) : BackgroundService { @@ -76,21 +75,9 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) try { - if (_validationOptions.RunValidationOnStartup) + if (!string.IsNullOrWhiteSpace(_startupCommandOptions.Command)) { - _logger.LogInformation("Running startup transport validation suite..."); - await RunTransportValidationSuiteAsync(stoppingToken).ConfigureAwait(false); - } - - if ( - string.Equals( - _startupCommandOptions.Command, - "run-transport-tests", - StringComparison.OrdinalIgnoreCase - ) - ) - { - string startupCommand = _startupCommandOptions.Command!; + string startupCommand = _startupCommandOptions.Command; _logger.LogInformation("Running startup command: {Command}", startupCommand); _ = await ProcessCommandAsync( startupCommand, @@ -677,10 +664,6 @@ CurrentProgramSceneChangedEventArgs sceneEvent in _obsClient.Scenes.CurrentProgr return false; } - case "run-transport-tests": - await RunTransportValidationSuiteAsync(cancellationToken).ConfigureAwait(false); - return false; - case "cleanup-fixtures": { // The sweeps name everything they create with a known prefix, so a run that dies @@ -817,5433 +800,842 @@ await _obsClient.MediaInputs.TriggerMediaActionAsync( } } - /// - /// Runs the live validation suite over both transports and reports a verdict, so the run can - /// be used as a gate rather than read. - /// - private async Task RunTransportValidationSuiteAsync(CancellationToken cancellationToken) + private IWebSocketMessageSerializer CreateSerializer(SerializationFormat format) => + format switch + { + SerializationFormat.MsgPack => new MsgPackMessageSerializer( + _loggerFactory.CreateLogger() + ), + _ => new JsonMessageSerializer(_loggerFactory.CreateLogger()), + }; + + private ( + bool Observed, + bool Valid, + int ExtensionBagCount, + int ExtensionEntryCount + ) ValidateStubExtensionData( + GetSceneListResponseData? scenes, + GetInputListResponseData? inputs, + string? firstInputName, + SerializationFormat format + ) { - int iterations = Math.Max(1, _validationOptions.ValidationIterations); - Rule rule = new("[cyan]Transport Validation[/]") { Justification = Justify.Left }; - AnsiConsole.Write(rule); + List?> extensionBags = + [ + .. (scenes?.Scenes ?? []).Select(scene => scene.ExtensionData), + .. (inputs?.Inputs ?? []).Select(input => input.ExtensionData), + ]; - List failures = []; - for (int i = 0; i < iterations; i++) - { - _logger.LogInformation( - "Running transport validation iteration {Current}/{Total} (JSON then MsgPack)...", - i + 1, - iterations - ); - UiInfo($"Iteration {i + 1}/{iterations}: JSON then MsgPack"); + int extensionBagCount = extensionBags.Count(bag => bag is { Count: > 0 }); + int extensionEntryCount = extensionBags + .Where(bag => bag is { Count: > 0 }) + .Sum(bag => bag!.Count); - foreach ( - SerializationFormat format in (SerializationFormat[]) - [SerializationFormat.Json, SerializationFormat.MsgPack] + bool valid = true; + foreach ( + Dictionary? bag in extensionBags.Where(bag => + bag is { Count: > 0 } ) + ) + { + foreach ((string _, JsonElement value) in bag!) { - try - { - failures.AddRange( - ( - await RunTransportValidationCycleAsync(format, cancellationToken) - .ConfigureAwait(false) - ).Select(failure => $"iteration {i + 1}, {format}: {failure}") - ); - } - catch (Exception ex) when (ex is not OperationCanceledException) + if (!IsValidExtensionDataValue(value)) { - // A failed validation, not a crash; the other transport still runs. - _logger.LogError(ex, "The {Format} validation cycle threw.", format); - failures.Add( - $"iteration {i + 1}, {format}: threw {ex.GetType().Name}: {ex.Message}" - ); + valid = false; + break; } } + + if (!valid) + { + break; + } } - if (failures.Count == 0) + bool observed = extensionBagCount > 0; + if (observed) { - UiSuccess($"Transport validation passed over {iterations} iteration(s)."); - return; + _logger.LogInformation( + "[{Format}] Stub ExtensionData validated: {BagCount} bag(s), {EntryCount} entries.", + format, + extensionBagCount, + extensionEntryCount + ); } - - UiError($"Transport validation failed with {failures.Count} check(s):"); - foreach (string failure in failures) + else { - UiError($" {failure}"); + _logger.LogWarning( + "[{Format}] Stub ExtensionData was not present in GetSceneList/GetInputList responses for input '{InputName}'.", + format, + firstInputName ?? "N/A" + ); } - _logger.LogError( - "Transport validation failed with {FailureCount} check(s).", - failures.Count - ); - - // The host shuts down gracefully, so the exit code carries the verdict. - Environment.ExitCode = 1; + return (observed, valid, extensionBagCount, extensionEntryCount); } - /// - /// Runs one transport's validation cycle. - /// - /// The wire format to validate. - /// A token to cancel the cycle. - /// The checks that failed, empty when the cycle passed. - private async Task> RunTransportValidationCycleAsync( - SerializationFormat format, - CancellationToken cancellationToken - ) + private static bool IsValidExtensionDataValue(JsonElement value) { - ObsWebSocketClientOptions cycleOptions = CloneOptionsForFormat(format); - - await using ObsWebSocketClient cycleClient = new( - _loggerFactory.CreateLogger(), - CreateSerializer, - Options.Create(cycleOptions), - _connectionFactory - ); - - await cycleClient.ConnectAsync(cancellationToken).ConfigureAwait(false); - // Each transport is judged on its own run. - SerializationFailureSink.Reset(); - - ObsSourceKinds kinds = await ObsSourceKinds - .ResolveAsync(cycleClient, cancellationToken) - .ConfigureAwait(false); - - // Enumerated once, before anything in the cycle changes OBS, and shared from there on. - // Asking again later reads an encoder the state change already freed, and OBS dies - // rather than answering (#25). - GetOutputListResponseData outputs = await cycleClient - .Outputs.GetOutputListAsync(cancellationToken) - .ConfigureAwait(false); try { - GetVersionResponseData? version = await cycleClient - .General.GetVersionAsync(cancellationToken: cancellationToken) - .ConfigureAwait(false); - if ( - version is null - || string.IsNullOrWhiteSpace(version.ObsVersion) - || string.IsNullOrWhiteSpace(version.ObsWebSocketVersion) - || version.RpcVersion <= 0 - ) + if (value.ValueKind == JsonValueKind.Undefined) { - throw new InvalidOperationException( - $"[{format}] Invalid GetVersion response. ObsVersion='{version?.ObsVersion}', ObsWebSocketVersion='{version?.ObsWebSocketVersion}', RpcVersion={version?.RpcVersion}." - ); + return false; } - GetVersionResponseData validatedVersion = version; - - _logger.LogInformation( - "[{Format}] Connected to OBS {ObsVersion} (RPC {RpcVersion})", - format, - validatedVersion.ObsVersion, - validatedVersion.RpcVersion - ); + _ = value.GetRawText(); + return true; + } + catch (Exception) + { + return false; + } + } - GetSceneListResponseData? scenes = await cycleClient - .Scenes.GetSceneListAsync(new(), cancellationToken) - .ConfigureAwait(false); - if (scenes?.Scenes is null || scenes.Scenes.Count == 0) - { - throw new InvalidOperationException($"[{format}] GetSceneList returned no scenes."); - } + private static bool TryFindCustomEventPayloadByTestId( + JsonElement source, + string testId, + out JsonElement payload + ) => TryFindCustomEventPayloadByTestIdCore(source, testId, depth: 0, out payload); - _logger.LogInformation( - "[{Format}] Scene stubs deserialized: {SceneCount}", - format, - scenes?.Scenes?.Count ?? 0 - ); - int sceneCount = scenes?.Scenes?.Count ?? 0; + private static bool TryFindCustomEventPayloadByTestIdCore( + JsonElement source, + string testId, + int depth, + out JsonElement payload + ) + { + payload = default; + if (depth > 8) + { + return false; + } - GetInputListResponseData? inputs = await cycleClient - .Inputs.GetInputListAsync(new(), cancellationToken) - .ConfigureAwait(false); - if (inputs?.Inputs is null || inputs.Inputs.Count == 0) + switch (source.ValueKind) + { + case JsonValueKind.Object: { - // A scene collection made fresh in the UI has no inputs at all. The suite supplies - // one rather than refusing to run, since every fixture it needs it creates anyway. - _logger.LogInformation( - "[{Format}] No inputs in this collection; creating one to validate against.", - format - ); - await cycleClient - .Inputs.CreateInputAsync( - new( - inputName: $"__obsws_seed_{Guid.NewGuid():N}"[..24], - inputKind: "color_source_v3", - sceneName: scenes!.Scenes[0].SceneName - ), - cancellationToken - ) - .ConfigureAwait(false); + if ( + source.TryGetProperty("testId", out JsonElement idProperty) + && idProperty.ValueKind == JsonValueKind.String + && string.Equals(idProperty.GetString(), testId, StringComparison.Ordinal) + ) + { + payload = source.Clone(); + return true; + } - inputs = await cycleClient - .Inputs.GetInputListAsync(new(), cancellationToken) - .ConfigureAwait(false); - } + foreach (JsonProperty property in source.EnumerateObject()) + { + if ( + TryFindCustomEventPayloadByTestIdCore( + property.Value, + testId, + depth + 1, + out payload + ) + ) + { + return true; + } + } - if (inputs?.Inputs is null || inputs.Inputs.Count == 0) - { - throw new InvalidOperationException($"[{format}] GetInputList returned no inputs."); + return false; } - _logger.LogInformation( - "[{Format}] Input stubs deserialized: {InputCount}", - format, - inputs?.Inputs?.Count ?? 0 - ); - int inputCount = inputs?.Inputs?.Count ?? 0; - - int filterCount = 0; - string? inputName = inputs?.Inputs?.FirstOrDefault()?.InputName; - if (!string.IsNullOrWhiteSpace(inputName)) + case JsonValueKind.Array: { - GetSourceFilterListResponseData? filters = await cycleClient - .Filters.GetSourceFilterListAsync( - new GetSourceFilterListRequestData(sourceName: inputName), - cancellationToken + foreach (JsonElement element in source.EnumerateArray()) + { + if ( + TryFindCustomEventPayloadByTestIdCore( + element, + testId, + depth + 1, + out payload + ) ) - .ConfigureAwait(false); - _logger.LogInformation( - "[{Format}] Filter stubs deserialized for '{InputName}': {FilterCount}", - format, - inputName, - filters?.Filters?.Count ?? 0 - ); - filterCount = filters?.Filters?.Count ?? 0; - } + { + return true; + } + } - GetSourceFilterKindListResponseData? filterKinds = await cycleClient - .Filters.GetSourceFilterKindListAsync(cancellationToken) - .ConfigureAwait(false); - if (filterKinds?.SourceFilterKinds is null || filterKinds.SourceFilterKinds.Count == 0) - { - throw new InvalidOperationException( - $"[{format}] GetSourceFilterKindList returned no filter kinds." - ); + return false; } - _logger.LogInformation( - "[{Format}] Filter kind entries: {KindCount}", - format, - filterKinds?.SourceFilterKinds?.Count ?? 0 - ); - int filterKindCount = filterKinds?.SourceFilterKinds?.Count ?? 0; + case JsonValueKind.String: + { + string? rawString = source.GetString(); + if (string.IsNullOrWhiteSpace(rawString)) + { + return false; + } - ( - bool extensionDataObserved, - bool extensionDataValid, - int extensionBagCount, - int extensionEntryCount - ) = ValidateStubExtensionData(scenes, inputs, inputName, format); + string trimmed = rawString.Trim(); + if (!trimmed.StartsWith('{') && !trimmed.StartsWith('[')) + { + return false; + } - if (extensionDataObserved && !extensionDataValid) - { - throw new InvalidOperationException( - $"[{format}] Stub ExtensionData validation failed." - ); + try + { + using JsonDocument parsed = JsonDocument.Parse(trimmed); + return TryFindCustomEventPayloadByTestIdCore( + parsed.RootElement, + testId, + depth + 1, + out payload + ); + } + catch (JsonException) + { + return false; + } } - string testId = $"{format}-custom-{Guid.NewGuid():N}"; - JsonElement customPayload = JsonDocument - .Parse( - $$""" - { - "testId": "{{testId}}", - "format": "{{format}}", - "nested": { - "enabled": true, - "levels": [1, 2, 3] - } - } - """ - ) - .RootElement.Clone(); - - Task waitForCustomEvent = - cycleClient.WaitForEventAsync( - predicate: _ => true, - timeout: TimeSpan.FromSeconds(2), - cancellationToken: cancellationToken - ); + default: + return false; + } + } - await cycleClient - .General.BroadcastCustomEventAsync( - new BroadcastCustomEventRequestData(customPayload), - cancellationToken - ) - .ConfigureAwait(false); + private ObsWebSocketClientOptions CloneOptionsForFormat(SerializationFormat format) => + new() + { + ServerUri = _baseOptions.ServerUri, + Password = _baseOptions.Password, + EventSubscriptions = _baseOptions.EventSubscriptions, + HandshakeTimeoutMs = _baseOptions.HandshakeTimeoutMs, + RequestTimeoutMs = _baseOptions.RequestTimeoutMs, + Format = format, + AutoReconnectEnabled = false, + InitialReconnectDelayMs = _baseOptions.InitialReconnectDelayMs, + MaxReconnectAttempts = _baseOptions.MaxReconnectAttempts, + ReconnectBackoffMultiplier = _baseOptions.ReconnectBackoffMultiplier, + MaxReconnectDelayMs = _baseOptions.MaxReconnectDelayMs, + }; - CustomEventEventArgs? customEvent = null; - try - { - customEvent = await waitForCustomEvent.ConfigureAwait(false); - } - catch (TimeoutException) - { - // Reported below as unverified. - } - bool customEventVerified = false; - if ( - customEvent?.EventData.EventData is JsonElement receivedCustomData - && TryFindCustomEventPayloadByTestId( - receivedCustomData, - testId, - out JsonElement actualCustomData - ) - && actualCustomData.GetProperty("testId").GetString() == testId - && actualCustomData.GetProperty("nested").GetProperty("enabled").GetBoolean() - && actualCustomData.GetProperty("nested").GetProperty("levels").GetArrayLength() - == 3 - ) - { - customEventVerified = true; - _logger.LogInformation( - "[{Format}] CustomEvent roundtrip verified with testId {TestId}.", - format, - testId - ); - } - else - { - _logger.LogWarning( - "[{Format}] CustomEvent payload verification skipped (event received: {Received}).", - format, - customEvent is not null - ); - } - - // The low level path, on purpose: the raw overload takes request items rather than the - // typed builder, and still works for anyone who needs to hand roll a batch. - List> batch = await cycleClient - .CallBatchAsync( - [new("GetVersion", null), new("GetSceneList", null)], - executionType: RequestBatchExecutionType.SerialRealtime, - haltOnFailure: false, - cancellationToken: cancellationToken - ) - .ConfigureAwait(false); - if (batch.Count != 2 || batch.Any(result => !result.RequestStatus.Result)) - { - throw new InvalidOperationException( - $"[{format}] Batch validation failed. Count={batch.Count}." - ); - } + // --- Helper to find Scene Item ID --- + private async Task GetSceneItemIdAsync( + string sceneName, + string sourceName, + CancellationToken cancellationToken + ) + { + GetSceneItemIdResponseData? response = await _obsClient.SceneItems.GetSceneItemIdAsync( + new GetSceneItemIdRequestData { SceneName = sceneName, SourceName = sourceName }, + cancellationToken: cancellationToken + ); - _logger.LogInformation( - "[{Format}] Batch call results: {ResultCount}", - format, - batch.Count - ); + return response?.SceneItemId == null + ? throw new SceneItemNotFoundException( + $"Source '{sourceName}' not found in scene '{sceneName}'." + ) + : response.SceneItemId; + } - List<(string Label, bool Pass, string Detail)> settingsResults = - await ValidateSettingsModesAsync(cycleClient, inputs, kinds, cancellationToken) - .ConfigureAwait(false); + /// + /// Shows what resolving a handle costs, what it buys, and what a miss reports. + /// + /// + /// The rest of the interactive commands address things by name, which is right for a name the + /// operator just typed. This one is the counterpart: it turns a name into a uuid once, and + /// everything after that survives a rename in OBS. + /// + private async Task ResolveDemoAsync( + string sceneName, + string? sourceName, + CancellationToken cancellationToken + ) + { + // One round trip. The protocol has no "uuid of the scene called X" request, so this is + // GetSceneList and a scan. + SceneOperations resolved = await _obsClient + .Scene(sceneName) + .ResolveAsync(cancellationToken); - List<(string Label, bool Pass, string Detail)> modernResults = - await ValidateModernApisAsync( - cycleClient, - healthChecks, - kinds, - outputs, - cancellationToken - ) - .ConfigureAwait(false); + RenderKeyValueTable( + "Resolved scene", + [ + ("Given", sceneName), + ("UUID", resolved.Handle.Uuid ?? "N/A"), + ("Survives a rename", resolved.Handle.IsResolved ? "yes" : "no"), + ] + ); - modernResults.AddRange( - await SweepEveryReadRequestAsync(cycleClient, kinds, outputs, cancellationToken) - .ConfigureAwait(false) - ); + // Held by uuid, so this reads the same scene even if it is renamed between the two calls. + GetSceneItemListResponseData items = await resolved.GetItemListAsync(cancellationToken); + UiInfo($"'{sceneName}' holds {items.SceneItems.Count} scene item(s)."); - modernResults.AddRange( - await SweepEveryWriteRequestAsync(cycleClient, kinds, outputs, cancellationToken) - .ConfigureAwait(false) + if (sourceName is not null) + { + // The one lookup that is not a convenience: OBS addresses scene items by a number that + // only GetSceneItemId reports, so an item known by source name cannot be acted on until + // it has been resolved. The type system says so: ItemAsync returns the actable type. + SceneItemOperations item = await resolved.ItemAsync( + sourceName, + cancellationToken: cancellationToken ); + GetSceneItemEnabledResponseData enabled = await item.GetEnabledAsync(cancellationToken); - // Last, so it covers every check above it. An event whose payload cannot be read is - // dropped rather than raised, which is deliberate and silent; this is what makes it - // loud during validation. - string[] unreadable = [.. SerializationFailureSink.Failures.Distinct()]; - modernResults.Add( - ( - "No unreadable payloads", - unreadable.Length == 0, - unreadable.Length == 0 - ? "every payload the run received deserialized" - : string.Join(" | ", unreadable.Take(3)) - ) + RenderKeyValueTable( + "Resolved scene item", + [ + ("Source", sourceName), + ("Item id", item.Handle.SceneItemId.ToString(CultureInfo.InvariantCulture)), + ("Enabled", enabled.SceneItemEnabled ? "yes" : "no"), + ("Back up to", item.Scene.Handle.Uuid ?? item.Scene.Handle.Name ?? "N/A"), + ] ); + } - Table summary = new() { Title = new TableTitle($"{format} Validation Summary") }; - _ = summary.AddColumn("Check"); - _ = summary.AddColumn("Result"); - _ = summary.AddRow("OBS Version", Markup.Escape(validatedVersion.ObsVersion ?? "N/A")); - _ = summary.AddRow("RPC", validatedVersion.RpcVersion.ToString()); - _ = summary.AddRow("Scenes", sceneCount.ToString()); - _ = summary.AddRow("Inputs", inputCount.ToString()); - _ = summary.AddRow("Filters (first input)", filterCount.ToString()); - _ = summary.AddRow("Filter Kinds", filterKindCount.ToString()); - _ = summary.AddRow( - "Stub ExtensionData", - extensionDataObserved - ? $"[green]Pass[/] ({extensionBagCount} bag(s), {extensionEntryCount} entries)" - : "[yellow]Unverified[/]" - ); - _ = summary.AddRow( - "CustomEvent", - customEventVerified ? "[green]Pass[/]" : "[yellow]Unverified[/]" + // The lookup already fetched the list, so a miss can name what + // does exist. OBS itself can only answer ResourceNotFound and the name you gave it. + try + { + _ = await _obsClient.Scenes.ResolveAsync( + sceneName + "__no_such_scene", + cancellationToken ); - _ = summary.AddRow("Batch", $"{batch.Count} result(s)"); - foreach ((string label, bool pass, string detail) in settingsResults) - { - _ = summary.AddRow( - Markup.Escape(label), - pass - ? $"[green]Pass[/]: {Markup.Escape(detail)}" - : $"[red]Fail[/]: {Markup.Escape(detail)}" - ); - } - foreach ((string label, bool pass, string detail) in modernResults) - { - _ = summary.AddRow( - Markup.Escape(label), - pass - ? $"[green]Pass[/] - {Markup.Escape(detail)}" - : $"[red]Fail[/] - {Markup.Escape(detail)}" - ); - } - AnsiConsole.Write(summary); - - return - [ - .. settingsResults - .Concat(modernResults) - .Where(result => !result.Pass) - .Select(result => $"{result.Label} ({result.Detail})"), - ]; } - finally + catch (ObsWebSocketResourceNotFoundException ex) { - if (cycleClient.IsConnected) - { - await cycleClient - .DisconnectAsync(cancellationToken: CancellationToken.None) - .ConfigureAwait(false); - } + UiInfo($"A miss reports what exists: {ex.Message}"); } } - /// - /// Validates all three settings API modes for both InputSettings and FilterSettings. - /// - /// - /// The browser source and the gain filter are created here and removed again, so a fresh OBS - /// install exercises the same checks as a populated one. Discovering an existing input instead - /// made the result depend on the machine: the run reported the modes as failing when all that - /// was missing was a source to try them on. - /// - private static async Task< - List<(string Label, bool Pass, string Detail)> - > ValidateSettingsModesAsync( - ObsWebSocketClient client, - GetInputListResponseData? inputs, - ObsSourceKinds kinds, - CancellationToken cancellationToken - ) + private async Task GetAllSettingsTypesAsync(CancellationToken cancellationToken) { - List<(string Label, bool Pass, string Detail)> results = []; - if (inputs is null) - { - results.Add(("Settings [all modes]", false, "GetInputList returned null")); - return results; - } + _logger.LogInformation("Fetching all settings type schemas from OBS..."); - if (kinds.Browser is null || kinds.GainFilter is null) - { - results.Add( - ("Settings [all modes]", true, $"skipped: no {kinds.Describe()} kind on this OBS") - ); - return results; - } + await DumpKindDefaultSettingsAsync( + "Filter Kind Defaults", + async ct => + { + GetSourceFilterKindListResponseData? r = + await _obsClient.Filters.GetSourceFilterKindListAsync(cancellationToken: ct); + return r?.SourceFilterKinds ?? []; + }, + async (kind, ct) => + { + GetSourceFilterDefaultSettingsResponseData? r = + await _obsClient.Filters.GetSourceFilterDefaultSettingsAsync( + new GetSourceFilterDefaultSettingsRequestData(kind), + cancellationToken: ct + ); + return r?.DefaultFilterSettings; + }, + cancellationToken + ); - // ── InputSettings ───────────────────────────────────────────────────── - const string FixtureInputName = "__obsws_settings_browser"; - const string FixtureFilter = "__obsws_settings_gain"; + await DumpKindDefaultSettingsAsync( + "Input Kind Defaults", + async ct => + { + GetInputKindListResponseData? r = await _obsClient.Inputs.GetInputKindListAsync( + new GetInputKindListRequestData(unversioned: false), + cancellationToken: ct + ); + return r?.InputKinds ?? []; + }, + async (kind, ct) => + { + GetInputDefaultSettingsResponseData? r = + await _obsClient.Inputs.GetInputDefaultSettingsAsync( + new GetInputDefaultSettingsRequestData(kind), + cancellationToken: ct + ); + return r?.DefaultInputSettings; + }, + cancellationToken + ); - string? browserInputName = null; - string? filterSourceName = null; - string? gainFilterName = null; + await DumpOutputSettingsAsync(cancellationToken); + await DumpStreamServiceSettingsAsync(cancellationToken); + } + private async Task DumpKindDefaultSettingsAsync( + string panelTitle, + Func>> getKinds, + Func> getDefaults, + CancellationToken cancellationToken + ) + { + List kinds; try { - GetCurrentProgramSceneResponseData programScene = await client - .Scenes.GetCurrentProgramSceneAsync(cancellationToken) - .ConfigureAwait(false); - - await client - .Inputs.CreateInputAsync( - inputKind: kinds.Browser, - inputName: FixtureInputName, - settings: new BrowserSourceSettings( - Url: "https://obsproject.com", - Width: 800, - Height: 600 - ), - sceneName: programScene.SceneName, - sceneItemEnabled: true, - cancellationToken: cancellationToken - ) - .ConfigureAwait(false); - browserInputName = FixtureInputName; - - await client - .Filters.CreateSourceFilterAsync( - new CreateSourceFilterRequestData( - filterKind: kinds.GainFilter, - filterName: FixtureFilter, - sourceName: FixtureInputName - ), - cancellationToken - ) - .ConfigureAwait(false); - filterSourceName = FixtureInputName; - gainFilterName = FixtureFilter; + kinds = await getKinds(cancellationToken); } - catch (ObsWebSocketException ex) + catch (Exception ex) { - results.Add(("Settings fixtures", false, $"could not be created: {ex.Message}")); + _logger.LogError(ex, "Failed to retrieve kind list for '{Panel}'.", panelTitle); + UiError($"[red]Could not retrieve kind list for {panelTitle}.[/]"); + return; } - try + if (kinds.Count == 0) { - results.AddRange( - await RunSettingsModeChecksAsync( - client, - browserInputName, - filterSourceName, - gainFilterName, - cancellationToken - ) - .ConfigureAwait(false) - ); + _logger.LogWarning("No kinds returned for '{Panel}'.", panelTitle); + return; } - finally + + _logger.LogInformation( + "Found {Count} kinds for '{Panel}'. Fetching defaults...", + kinds.Count, + panelTitle + ); + + Dictionary results = new(StringComparer.OrdinalIgnoreCase); + foreach (string kind in kinds) { - // Removing the input takes its filter and its scene item with it. - if (browserInputName is not null) + try { - try - { - await client - .Inputs.RemoveInputAsync( - new(inputName: browserInputName), - CancellationToken.None - ) - .ConfigureAwait(false); - } - catch (ObsWebSocketException) - { - // Nothing useful to do about a fixture that will not go away. The next run - // recreates it by the same name and OBS rejects the duplicate visibly. - } + results[kind] = await getDefaults(kind, cancellationToken); + } + catch (ObsWebSocketException ex) + { + _logger.LogWarning("Could not get defaults for '{Kind}': {Msg}", kind, ex.Message); + results[kind] = null; + } + catch (Exception ex) + { + _logger.LogError(ex, "Unexpected error getting defaults for '{Kind}'.", kind); + results[kind] = null; } } - return results; + RenderJsonPanel(panelTitle, SerializeKindDefaults(results)); } - /// - /// The settings-mode checks themselves, against fixtures the caller creates and removes. - /// - private static async Task< - List<(string Label, bool Pass, string Detail)> - > RunSettingsModeChecksAsync( - ObsWebSocketClient client, - string? browserInputName, - string? filterSourceName, - string? gainFilterName, - CancellationToken cancellationToken - ) + private async Task DumpOutputSettingsAsync(CancellationToken cancellationToken) { - List<(string Label, bool Pass, string Detail)> results = []; - - if (string.IsNullOrEmpty(browserInputName)) + List outputs; + try { - results.Add( - ("InputSettings [all modes]", false, "fixture browser source was not created") + GetOutputListResponseData? response = await _obsClient.Outputs.GetOutputListAsync( + cancellationToken: cancellationToken ); + outputs = response?.Outputs ?? []; } - else + catch (Exception ex) { - // Mode 1: raw JsonElement via protocol-level call - results.Add( - await TrySettingsCheckAsync( - "InputSettings Mode1 (raw JsonElement)", - async () => - { - GetInputSettingsResponseData? r = await client.Inputs.GetInputSettingsAsync( - new GetInputSettingsRequestData(browserInputName), - cancellationToken - ); - if (r?.InputSettings is not JsonElement el) - { - return (false, "null InputSettings in response"); - } - - await client.Inputs.SetInputSettingsAsync( - new SetInputSettingsRequestData( - el, - inputName: browserInputName, - overlay: true - ), - cancellationToken - ); - string url = el.TryGetProperty("url", out JsonElement p) - ? p.GetString() ?? "(no url)" - : "(no url key)"; - return (true, $"'{browserInputName}' url={url}"); - } - ) - ); + _logger.LogError(ex, "Failed to retrieve output list."); + UiError("[red]Could not retrieve output list.[/]"); + return; + } - // Mode 2: library-registered type via implicit GetTypeInfo lookup - results.Add( - await TrySettingsCheckAsync( - "InputSettings Mode2 (BrowserSourceSettings)", - async () => - { - BrowserSourceSettings? s = - await client.Inputs.GetInputSettingsAsync( - browserInputName, - cancellationToken - ); - if (s is null) - { - return (false, "null result"); - } - - await client.Inputs.SetInputSettingsAsync( - browserInputName, - s, - overlay: true, - cancellationToken: cancellationToken - ); - return (true, $"'{browserInputName}' url={s.Url ?? "(null)"}"); - } - ) - ); - - // Mode 3: consumer-defined type with explicit JsonTypeInfo - results.Add( - await TrySettingsCheckAsync( - "InputSettings Mode3 (consumer JsonTypeInfo)", - async () => - { - JsonTypeInfo typeInfo = WorkerSettingsJsonContext - .Default - .WorkerBrowserUrlSettings; - WorkerBrowserUrlSettings? s = await client.Inputs.GetInputSettingsAsync( - browserInputName, - typeInfo, - cancellationToken - ); - if (s is null) - { - return (false, "null result"); - } - - await client.Inputs.SetInputSettingsAsync( - browserInputName, - s, - typeInfo, - overlay: true, - cancellationToken: cancellationToken - ); - return (true, $"'{browserInputName}' url={s.Url ?? "(null)"}"); - } - ) - ); + if (outputs.Count == 0) + { + _logger.LogWarning("No outputs configured on this OBS instance."); + return; } - // ── FilterSettings ──────────────────────────────────────────────────── - if (string.IsNullOrEmpty(filterSourceName) || string.IsNullOrEmpty(gainFilterName)) + Dictionary results = new(StringComparer.OrdinalIgnoreCase); + foreach (OutputStub output in outputs) { - results.Add( - ("FilterSettings [all modes]", false, "fixture gain filter was not created") - ); + if (output.OutputName is not { } name) + { + continue; + } + + string key = output.OutputKind is { } kind ? $"{name} ({kind})" : name; + try + { + GetOutputSettingsResponseData? r = await _obsClient.Outputs.GetOutputSettingsAsync( + new GetOutputSettingsRequestData(outputName: name), + cancellationToken: cancellationToken + ); + results[key] = r?.OutputSettings; + } + catch (ObsWebSocketException ex) + { + _logger.LogWarning( + "Could not get settings for output '{Name}': {Msg}", + name, + ex.Message + ); + results[key] = null; + } + catch (Exception ex) + { + _logger.LogError( + ex, + "Unexpected error getting settings for output '{Name}'.", + name + ); + results[key] = null; + } } - else - { - // Mode 1: raw JsonElement via protocol-level call - results.Add( - await TrySettingsCheckAsync( - "FilterSettings Mode1 (raw JsonElement)", - async () => - { - GetSourceFilterResponseData? r = await client.Filters.GetSourceFilterAsync( - new GetSourceFilterRequestData - { - SourceName = filterSourceName, - FilterName = gainFilterName, - }, - cancellationToken - ); - if (r?.FilterSettings is not JsonElement el) - { - return (false, "null FilterSettings in response"); - } - await client.Filters.SetSourceFilterSettingsAsync( - new SetSourceFilterSettingsRequestData( - gainFilterName, - el, - sourceName: filterSourceName, - overlay: true - ), - cancellationToken - ); - string db = el.TryGetProperty("db", out JsonElement p) - ? p.GetDouble().ToString("F1") - : "(no db key)"; - return (true, $"'{filterSourceName}/{gainFilterName}' db={db}"); - } - ) - ); + RenderJsonPanel("Output Settings (current instances)", SerializeKindDefaults(results)); + } - // Mode 2: library-registered type via implicit GetTypeInfo lookup - results.Add( - await TrySettingsCheckAsync( - "FilterSettings Mode2 (GainFilterSettings)", - async () => - { - GainFilterSettings? s = - await client.Filters.GetSourceFilterSettingsAsync( - filterSourceName, - gainFilterName, - cancellationToken - ); - if (s is null) - { - return (false, "null result"); - } + private async Task DumpStreamServiceSettingsAsync(CancellationToken cancellationToken) + { + try + { + GetStreamServiceSettingsResponseData? response = + await _obsClient.Config.GetStreamServiceSettingsAsync( + cancellationToken: cancellationToken + ); - await client.Filters.SetSourceFilterSettingsAsync( - filterSourceName, - gainFilterName, - s, - overlay: true, - cancellationToken: cancellationToken - ); - return ( - true, - $"'{filterSourceName}/{gainFilterName}' db={s.Db?.ToString("F1") ?? "(null)"}" - ); - } - ) - ); + ArrayBufferWriter buf = new(); + using (Utf8JsonWriter w = new(buf, new JsonWriterOptions { Indented = true })) + { + w.WriteStartObject(); + w.WriteString("streamServiceType", response?.StreamServiceType); + w.WritePropertyName("streamServiceSettings"); + if (response?.StreamServiceSettings is JsonElement el) + { + el.WriteTo(w); + } + else + { + w.WriteNullValue(); + } - // Mode 3: consumer-defined type with explicit JsonTypeInfo - results.Add( - await TrySettingsCheckAsync( - "FilterSettings Mode3 (consumer JsonTypeInfo)", - async () => - { - JsonTypeInfo typeInfo = WorkerSettingsJsonContext - .Default - .WorkerGainDbSettings; - WorkerGainDbSettings? s = await client.Filters.GetSourceFilterSettingsAsync( - filterSourceName, - gainFilterName, - typeInfo, - cancellationToken - ); - if (s is null) - { - return (false, "null result"); - } + w.WriteEndObject(); + w.Flush(); + } - await client.Filters.SetSourceFilterSettingsAsync( - filterSourceName, - gainFilterName, - s, - typeInfo, - overlay: true, - cancellationToken: cancellationToken - ); - return ( - true, - $"'{filterSourceName}/{gainFilterName}' db={s.Db?.ToString("F1") ?? "(null)"}" - ); - } - ) + RenderJsonPanel( + "Stream Service Settings", + System.Text.Encoding.UTF8.GetString(buf.WrittenSpan) ); } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to retrieve stream service settings."); + UiError("[red]Could not retrieve stream service settings.[/]"); + } + } - return results; + private static string SerializeKindDefaults(Dictionary results) + { + ArrayBufferWriter buf = new(); + using Utf8JsonWriter w = new(buf, new JsonWriterOptions { Indented = true }); + w.WriteStartObject(); + foreach ((string kind, JsonElement? value) in results) + { + w.WritePropertyName(kind); + if (value is JsonElement el) + { + el.WriteTo(w); + } + else + { + w.WriteNullValue(); + } + } + w.WriteEndObject(); + w.Flush(); + return System.Text.Encoding.UTF8.GetString(buf.WrittenSpan); } - /// - /// Exercises the modern conveniences against a scene and input this method creates itself, - /// so the run does not depend on any particular OBS layout. Everything it makes is removed - /// again, whether the checks pass or not. - /// - private static async Task< - List<(string Label, bool Pass, string Detail)> - > ValidateModernApisAsync( - ObsWebSocketClient client, - HealthCheckService healthChecks, - ObsSourceKinds kinds, - GetOutputListResponseData outputs, - CancellationToken cancellationToken - ) + private async Task AddBrowserSourceAsync(CancellationToken cancellationToken) { - List<(string Label, bool Pass, string Detail)> results = []; + // Step 1: Fetch scene list and determine current program scene + GetSceneListResponseData? sceneList = await _obsClient.Scenes.GetSceneListAsync( + new(), + cancellationToken: cancellationToken + ); + + if (sceneList?.Scenes is null || sceneList.Scenes.Count == 0) + { + UiWarn("Could not retrieve scene list from OBS."); + return; + } - string suffix = Guid.NewGuid().ToString("N")[..8]; - string sceneName = $"__obsws_validation_{suffix}"; - string inputName = $"__obsws_input_{suffix}"; + string currentProgramScene = sceneList.CurrentProgramSceneName ?? string.Empty; - GetSceneListResponseData? sceneList = await client - .Scenes.GetSceneListAsync(new(), cancellationToken) - .ConfigureAwait(false); - string originalScene = sceneList?.CurrentProgramSceneName ?? string.Empty; + List sceneNames = + [ + .. sceneList + .Scenes.Select(s => s.SceneName) + .Where(n => !string.IsNullOrEmpty(n)) + .Select(n => n!), + ]; - bool sceneCreated = false; - bool inputCreated = false; + // Place current program scene first, then alphabetically + List orderedSceneNames = !string.IsNullOrEmpty(currentProgramScene) + ? + [ + .. sceneNames.Where(n => n == currentProgramScene), + .. sceneNames.Where(n => n != currentProgramScene).OrderBy(n => n), + ] + : [.. sceneNames.OrderBy(n => n)]; - try + if (orderedSceneNames.Count == 0) { - await client - .Scenes.CreateSceneAsync(new CreateSceneRequestData(sceneName), cancellationToken) - .ConfigureAwait(false); - sceneCreated = true; - - results.Add( - await TrySettingsCheckAsync( - "SceneExistsAsync", - async () => - { - bool present = await client - .Scenes.SceneExistsAsync(sceneName, cancellationToken) - .ConfigureAwait(false); - bool absent = await client - .Scenes.SceneExistsAsync(sceneName + "__nope", cancellationToken) - .ConfigureAwait(false); - return (present && !absent, $"present={present}, absent={!absent}"); - } - ) - .ConfigureAwait(false) - ); + UiWarn("No scenes available in OBS."); + return; + } - // A media source carries audio, so the volume and media transport helpers apply. - _ = await client - .Inputs.CreateInputAsync( - "ffmpeg_source", - inputName, - new MediaSourceSettings(IsLocalFile: true), - sceneName: sceneName, - cancellationToken: cancellationToken - ) - .ConfigureAwait(false); - inputCreated = true; + // Map display labels (with "(current program)" suffix) to actual names + Dictionary displayToSceneName = orderedSceneNames.ToDictionary( + n => n == currentProgramScene ? $"{n} (current program)" : n, + n => n + ); - results.Add( - await TrySettingsCheckAsync( - "FindSceneItemIdAsync", - async () => - { - double? hit = await client - .SceneItems.FindSceneItemIdAsync( - sceneName, - inputName, - cancellationToken - ) - .ConfigureAwait(false); - double? miss = await client - .SceneItems.FindSceneItemIdAsync( - sceneName, - "__not_here__", - cancellationToken - ) - .ConfigureAwait(false); - return ( - hit is not null && miss is null, - $"hit={hit}, miss={(miss is null ? "null" : "unexpected")}" - ); - } - ) - .ConfigureAwait(false) - ); + // Step 2: Prompt user to select a scene + string selectedSceneDisplay = AnsiConsole.Prompt( + new SelectionPrompt() + .Title("Select target [cyan]scene[/]:") + .PageSize(15) + .AddChoices(displayToSceneName.Keys) + ); - results.Add( - await TrySettingsCheckAsync( - "SetSceneItemEnabledAsync (toggle)", - async () => - { - bool off = await client - .SceneItems.SetSceneItemEnabledAsync( - sceneName, - inputName, - false, - cancellationToken - ) - .ConfigureAwait(false); - bool toggled = await client - .SceneItems.SetSceneItemEnabledAsync( - sceneName, - inputName, - null, - cancellationToken - ) - .ConfigureAwait(false); - return (!off && toggled, $"set false -> {off}, toggled -> {toggled}"); - } - ) - .ConfigureAwait(false) - ); + string selectedScene = displayToSceneName[selectedSceneDisplay]; - results.Add( - await TrySettingsCheckAsync( - "SetInputVolumeDbAsync", - async () => - { - await client - .Inputs.SetInputVolumeDbAsync(inputName, -6, cancellationToken) - .ConfigureAwait(false); - GetInputVolumeResponseData? volume = await client - .Inputs.GetInputVolumeAsync( - new GetInputVolumeRequestData(inputName: inputName), - cancellationToken - ) - .ConfigureAwait(false); - double db = volume?.InputVolumeDb ?? double.NaN; - return (Math.Abs(db + 6) < 0.5, $"db={db:0.##}"); - } - ) - .ConfigureAwait(false) + // Step 3: Fetch scene items and all global browser_source inputs in parallel + Task sceneItemsTask = + _obsClient.SceneItems.GetSceneItemListAsync( + new GetSceneItemListRequestData(sceneName: selectedScene), + cancellationToken: cancellationToken ); + Task browserInputsTask = _obsClient.Inputs.GetInputListAsync( + new GetInputListRequestData("browser_source"), + cancellationToken: cancellationToken + ); - results.Add( - await TrySettingsCheckAsync( - "Media transport (typed enum)", - async () => - { - await client - .MediaInputs.TriggerMediaActionAsync( - inputName, - MediaInputAction.Stop, - cancellationToken - ) - .ConfigureAwait(false); - - // Read the state back, so this proves the action landed rather than only that - // the request was accepted. - GetMediaInputStatusResponseData? status = await client - .MediaInputs.GetMediaInputStatusAsync( - new GetMediaInputStatusRequestData(inputName: inputName), - cancellationToken - ) - .ConfigureAwait(false); - - string? state = status?.MediaState; - bool stopped = - state is not null - && state.Contains("STOPPED", StringComparison.Ordinal) - || state is not null - && state.Contains("NONE", StringComparison.Ordinal); - - return ( - stopped, - $"sent {MediaInputAction.Stop.ToWireValue()}, state={state}" - ); - } - ) - .ConfigureAwait(false) - ); + await Task.WhenAll(sceneItemsTask, browserInputsTask).ConfigureAwait(false); - results.Add( - await TrySettingsCheckAsync( - "Event stream (await foreach)", - async () => - { - using CancellationTokenSource streamCts = - CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - streamCts.CancelAfter(TimeSpan.FromSeconds(10)); - - List observed = []; - Task consume = Task.Run( - async () => - { - try - { - await foreach ( - CurrentProgramSceneChangedEventArgs sceneEvent in client - .Scenes.CurrentProgramSceneChangedStream( - cancellationToken: streamCts.Token - ) - .ConfigureAwait(false) - ) - { - observed.Add( - sceneEvent.EventData.SceneName ?? string.Empty - ); - if (observed.Count >= 2) - { - await streamCts.CancelAsync().ConfigureAwait(false); - } - } - } - catch (OperationCanceledException) - { - // Expected once both switches are seen or the window elapses. - } - }, - CancellationToken.None - ); - - await Task.Delay(250, cancellationToken).ConfigureAwait(false); - await client - .Scenes.SwitchProgramSceneAsync( - sceneName, - cancellationToken: cancellationToken - ) - .ConfigureAwait(false); - await Task.Delay(400, cancellationToken).ConfigureAwait(false); - if (!string.IsNullOrEmpty(originalScene)) - { - await client - .Scenes.SwitchProgramSceneAsync( - originalScene, - cancellationToken: cancellationToken - ) - .ConfigureAwait(false); - } - - await consume.ConfigureAwait(false); - return ( - observed.Count >= 2, - $"observed {observed.Count}: {string.Join(" -> ", observed)}" - ); - } - ) - .ConfigureAwait(false) - ); + GetSceneItemListResponseData? sceneItemList = await sceneItemsTask; + GetInputListResponseData? browserInputList = await browserInputsTask; - results.Add( - await TrySettingsCheckAsync( - "WaitForEventAsync (timeout overload)", - async () => - { - Task wait = - client.WaitForEventAsync( - TimeSpan.FromSeconds(5), - cancellationToken - ); - _ = await client - .SceneItems.SetSceneItemEnabledAsync( - sceneName, - inputName, - false, - cancellationToken - ) - .ConfigureAwait(false); - try - { - SceneItemEnableStateChangedEventArgs observed = - await wait.ConfigureAwait(false); - return (true, $"enabled={observed.EventData.SceneItemEnabled}"); - } - catch (TimeoutException) - { - return (false, "timed out"); - } - } - ) - .ConfigureAwait(false) - ); + // Find browser sources that already exist in the selected scene + HashSet sceneSourceNames = + sceneItemList + ?.SceneItems?.Select(si => si.SourceName ?? string.Empty) + .Where(n => !string.IsNullOrEmpty(n)) + .ToHashSet(StringComparer.OrdinalIgnoreCase) + ?? []; - results.Add( - await TrySettingsCheckAsync( - "Typed batch builder", - async () => - { - ObsBatchBuilder batch = new(); - BatchRef versionRef = - batch.General.GetVersion(); - _ = batch.General.Sleep(new SleepRequestData(sleepMillis: 25)); - BatchRef scenesRef = - batch.Scenes.GetSceneList(new GetSceneListRequestData()); - BatchRef statsRef = batch.General.GetStats(); - - BatchResults typedBatch = await client - .CallBatchAsync( - batch, - executionType: RequestBatchExecutionType.SerialRealtime, - haltOnFailure: false, - cancellationToken: cancellationToken - ) - .ConfigureAwait(false); - - // Each result is read through the reference its request handed back, so neither - // the position nor the response type is restated here. - GetVersionResponseData version = typedBatch.Get(versionRef); - GetSceneListResponseData scenes = typedBatch.Get(scenesRef); - GetStatsResponseData stats = typedBatch.Get(statsRef); - - return ( - typedBatch.Count == 4 - && typedBatch.AllSucceeded() - && version.ObsVersion is not null - && scenes.Scenes is not null, - $"{typedBatch.Count} result(s), OBS {version.ObsVersion}, {scenes.Scenes?.Count} scene(s), {stats.ActiveFps:0} fps" - ); - } - ) - .ConfigureAwait(false) - ); + List existingBrowserSourcesInScene = + browserInputList + ?.Inputs?.Where(i => sceneSourceNames.Contains(i.InputName ?? string.Empty)) + .Select(i => i.InputName!) + .Where(n => !string.IsNullOrEmpty(n)) + .OrderBy(n => n) + .ToList() + ?? []; - results.Add( - await TrySettingsCheckAsync( - "Batch order and duplicates", - async () => - { - // Repeats one request type with different payloads and interleaves others, so a - // result can only be matched to its request by position. - GetSceneListResponseData? allScenes = await client - .Scenes.GetSceneListAsync( - new GetSceneListRequestData(), - cancellationToken - ) - .ConfigureAwait(false); - string otherScene = allScenes! - .Scenes!.Select(scene => scene.SceneName!) - .First(name => - !string.Equals(name, sceneName, StringComparison.Ordinal) - ); - - // The same request type appears three times with two different payloads, so a - // result can only be matched to its request through the reference it returned. - ObsBatchBuilder mixedBatch = new(); - BatchRef firstRef = - mixedBatch.SceneItems.GetSceneItemList( - new GetSceneItemListRequestData(sceneName: sceneName) - ); - BatchRef versionRef = - mixedBatch.General.GetVersion(); - BatchRef secondRef = - mixedBatch.SceneItems.GetSceneItemList( - new GetSceneItemListRequestData(sceneName: otherScene) - ); - BatchRef thirdRef = - mixedBatch.SceneItems.GetSceneItemList( - new GetSceneItemListRequestData(sceneName: sceneName) - ); - _ = mixedBatch.General.GetStats(); - - BatchResults mixed = await client - .CallBatchAsync( - mixedBatch, - executionType: RequestBatchExecutionType.SerialRealtime, - haltOnFailure: false, - cancellationToken: cancellationToken - ) - .ConfigureAwait(false); - - if (mixed.Count != 5 || !mixed.AllSucceeded()) - { - return ( - false, - $"expected 5 successes, got {mixed.Count} with {mixed.GetFailures().Count()} failure(s)" - ); - } - - GetSceneItemListResponseData first = mixed.Get(firstRef); - GetVersionResponseData version = mixed.Get(versionRef); - GetSceneItemListResponseData second = mixed.Get(secondRef); - GetSceneItemListResponseData third = mixed.Get(thirdRef); - - // The two lookups of the same scene must agree, and differ from the other scene. - int firstCount = first.SceneItems?.Count ?? -1; - int secondCount = second.SceneItems?.Count ?? -1; - int thirdCount = third.SceneItems?.Count ?? -1; - bool repeatsAgree = firstCount == thirdCount; - bool distinguishable = - firstCount != secondCount - || !string.Equals(sceneName, otherScene, StringComparison.Ordinal); - - return ( - repeatsAgree && distinguishable && version.ObsVersion is not null, - $"[{firstCount}, v{version.ObsVersion}, {secondCount}, {thirdCount}] repeats agree = {repeatsAgree}" - ); - } - ) - .ConfigureAwait(false) - ); - - results.Add( - await TrySettingsCheckAsync( - "Batch partial failure", - async () => - { - // haltOnFailure false, so the good requests either side of a bad one still run. - ObsBatchBuilder partialBatch = new(); - BatchRef goodRef = - partialBatch.General.GetVersion(); - BatchRef badRef = - partialBatch.SceneItems.GetSceneItemList( - new GetSceneItemListRequestData(sceneName: "__no_such_scene__") - ); - _ = partialBatch.General.GetStats(); - - BatchResults partial = await client - .CallBatchAsync( - partialBatch, - executionType: RequestBatchExecutionType.SerialRealtime, - haltOnFailure: false, - cancellationToken: cancellationToken - ) - .ConfigureAwait(false); - - RequestResponsePayload[] failures = [.. partial.GetFailures()]; - if (partial.Count != 3 || failures.Length != 1) - { - return ( - false, - $"{partial.Count} result(s), {failures.Length} failure(s)" - ); - } - - // GetRequiredData surfaces the OBS status rather than a null payload. - string caught; - try - { - _ = partial.Get(badRef); - caught = "no exception"; - } - catch (ObsWebSocketRequestException ex) - { - caught = $"code {(int?)ex.StatusCode}"; - } - - // TryGet reports the failure without throwing. - bool tryGetReportedFailure = !partial.TryGet(badRef, out _); - bool neighboursOk = - tryGetReportedFailure - && partial.Get(goodRef).ObsVersion is not null; - - return ( - neighboursOk - && caught.StartsWith("code ", StringComparison.Ordinal), - $"1 failed ({caught}), neighbours ran" - ); - } - ) - .ConfigureAwait(false) - ); - - results.Add( - await TrySettingsCheckAsync( - "Event stream buffering", - async () => - { - // A stream keeps the newest events when a consumer falls behind rather than - // stalling the receive loop, so a small capacity drops the oldest. - using CancellationTokenSource cts = - CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - cts.CancelAfter(TimeSpan.FromSeconds(10)); - - IAsyncEnumerator enumerator = - client - .SceneItems.SceneItemEnableStateChangedStream( - capacity: 2, - cancellationToken: cts.Token - ) - .GetAsyncEnumerator(cts.Token); - - try - { - ValueTask pending = enumerator.MoveNextAsync(); - - // Toggle more times than the buffer holds. - for (int i = 0; i < 4; i++) - { - _ = await client - .SceneItems.SetSceneItemEnabledAsync( - sceneName, - inputName, - i % 2 == 0, - cancellationToken - ) - .ConfigureAwait(false); - } - - bool first = await pending.ConfigureAwait(false); - return ( - first, - first - ? "buffered and delivered under capacity pressure" - : "no event" - ); - } - finally - { - await enumerator.DisposeAsync().ConfigureAwait(false); - } - } - ) - .ConfigureAwait(false) - ); - - results.Add( - await TrySettingsCheckAsync( - "Single-request values (non-batch)", - async () => - { - // The same response types that come back empty inside a batch, fetched singly. - GetSceneItemListResponseData? items = await client - .SceneItems.GetSceneItemListAsync( - new GetSceneItemListRequestData(sceneName: sceneName), - cancellationToken - ) - .ConfigureAwait(false); - GetStatsResponseData? st = await client - .General.GetStatsAsync(cancellationToken) - .ConfigureAwait(false); - GetVersionResponseData? ver = await client - .General.GetVersionAsync(cancellationToken) - .ConfigureAwait(false); - - int itemCount = items?.SceneItems?.Count ?? -1; - double fps = st?.ActiveFps ?? 0; - - return ( - itemCount >= 0 && fps > 0 && ver?.ObsVersion is not null, - $"items={itemCount}, {fps:0} fps, v={ver?.ObsVersion}" - ); - } - ) - .ConfigureAwait(false) - ); - - results.Add( - await TrySettingsCheckAsync( - "Batch parallel execution", - async () => - { - // OBS pairs each result with another request's response data under parallel - // execution, so reading by reference must refuse rather than return the wrong - // request's payload. - ObsBatchBuilder par = new(); - BatchRef v = par.General.GetVersion(); - _ = par.SceneItems.GetSceneItemList( - new GetSceneItemListRequestData(sceneName: sceneName) - ); - - BatchResults r = await client - .CallBatchAsync( - par, - executionType: RequestBatchExecutionType.Parallel, - haltOnFailure: false, - cancellationToken: cancellationToken - ) - .ConfigureAwait(false); - - string guarded; - try - { - _ = r.Get(v); - guarded = "returned data"; - } - catch (ObsWebSocketException ex) - { - guarded = ex.Message.Contains("Parallel", StringComparison.Ordinal) - ? "refused" - : "threw: " + ex.Message; - } - - return ( - r.Count == 2 - && guarded == "refused" - && !r.TryGet(v, out GetVersionResponseData? _), - $"{r.Count} raw result(s), reference {guarded}" - ); - } - ) - .ConfigureAwait(false) - ); - - results.Add( - await TrySettingsCheckAsync( - "Batch halt on failure", - async () => - { - ObsBatchBuilder halt = new(); - BatchRef first = halt.General.GetVersion(); - BatchRef bad = - halt.SceneItems.GetSceneItemList( - new GetSceneItemListRequestData(sceneName: "__no_such_scene__") - ); - BatchRef never = halt.General.GetStats(); - - BatchResults r = await client - .CallBatchAsync( - halt, - executionType: RequestBatchExecutionType.SerialRealtime, - haltOnFailure: true, - cancellationToken: cancellationToken - ) - .ConfigureAwait(false); - - bool firstOk = r.Get(first).ObsVersion is not null; - bool badRejected = !r.TryGet(bad, out GetSceneItemListResponseData? _); - - // The third request never ran, so reading it explains itself rather than - // returning someone else's result. - string neverMsg; - try - { - _ = r.Get(never); - neverMsg = "returned a result"; - } - catch (ObsWebSocketException ex) - { - neverMsg = ex.Message.Contains( - "never ran", - StringComparison.Ordinal - ) - ? "explained" - : "threw: " + ex.Message; - } - - return ( - firstOk && badRejected && neverMsg == "explained", - $"{r.Count} result(s), unrun request {neverMsg}" - ); - } - ) - .ConfigureAwait(false) - ); - - results.Add( - await TrySettingsCheckAsync( - "SetInputVolumeMulAsync", - async () => - { - GetInputVolumeResponseData? before = await client - .Inputs.GetInputVolumeAsync( - new GetInputVolumeRequestData(inputName: inputName), - cancellationToken - ) - .ConfigureAwait(false); - double original = before!.InputVolumeMul; - - await client - .Inputs.SetInputVolumeMulAsync(inputName, 0.5, cancellationToken) - .ConfigureAwait(false); - GetInputVolumeResponseData? after = await client - .Inputs.GetInputVolumeAsync( - new GetInputVolumeRequestData(inputName: inputName), - cancellationToken - ) - .ConfigureAwait(false); - double mul = after!.InputVolumeMul; - - await client - .Inputs.SetInputVolumeMulAsync( - inputName, - original, - cancellationToken - ) - .ConfigureAwait(false); - return (Math.Abs(mul - 0.5) < 0.01, $"mul={mul:0.###}"); - } - ) - .ConfigureAwait(false) - ); - - results.Add( - await TrySettingsCheckAsync( - "SwitchProgramSceneAsync", - async () => - { - await client - .Scenes.SwitchProgramSceneAsync( - sceneName, - cancellationToken: cancellationToken - ) - .ConfigureAwait(false); - GetSceneListResponseData? mid = await client - .Scenes.GetSceneListAsync( - new GetSceneListRequestData(), - cancellationToken - ) - .ConfigureAwait(false); - bool switched = string.Equals( - mid?.CurrentProgramSceneName, - sceneName, - StringComparison.Ordinal - ); - - await client - .Scenes.SwitchProgramSceneAsync( - originalScene, - cancellationToken: cancellationToken - ) - .ConfigureAwait(false); - GetSceneListResponseData? restored = await client - .Scenes.GetSceneListAsync( - new GetSceneListRequestData(), - cancellationToken - ) - .ConfigureAwait(false); - - return ( - switched - && string.Equals( - restored?.CurrentProgramSceneName, - originalScene, - StringComparison.Ordinal - ), - $"switched={switched}, restored to '{restored?.CurrentProgramSceneName}'" - ); - } - ) - .ConfigureAwait(false) - ); - - results.Add( - await TrySettingsCheckAsync( - "FindSceneItemIdAsync", - async () => - { - long? id = await client - .SceneItems.FindSceneItemIdAsync( - sceneName, - inputName, - cancellationToken - ) - .ConfigureAwait(false); - long? miss = await client - .SceneItems.FindSceneItemIdAsync( - sceneName, - "__absent__", - cancellationToken - ) - .ConfigureAwait(false); - - return ( - id is not null && miss is null, - $"id={id}, miss={(miss is null ? "null" : "unexpected")}" - ); - } - ) - .ConfigureAwait(false) - ); - - results.Add( - await TrySettingsCheckAsync( - "Screenshot helpers", - async () => - { - byte[]? bytes = await client - .Sources.GetSourceScreenshotBytesAsync( - sceneName, - "png", - cancellationToken: cancellationToken - ) - .ConfigureAwait(false); - - string path = Path.Combine( - Path.GetTempPath(), - $"obsws_{Guid.NewGuid():N}.png" - ); - try - { - await client - .Sources.SaveSourceScreenshotToFileAsync( - sceneName, - path, - "png", - cancellationToken: cancellationToken - ) - .ConfigureAwait(false); - - // A PNG starts with the eight byte signature, so this checks real image data - // rather than merely that the call returned. - byte[] written = await File.ReadAllBytesAsync( - path, - cancellationToken - ) - .ConfigureAwait(false); - bool pngOnDisk = - written.Length > 8 - && written[0] == 0x89 - && written[1] == 0x50 - && written[2] == 0x4E - && written[3] == 0x47; - bool pngInMemory = - bytes is { Length: > 8 } - && bytes[0] == 0x89 - && bytes[1] == 0x50 - && bytes[2] == 0x4E - && bytes[3] == 0x47; - - return ( - pngInMemory && pngOnDisk, - $"{bytes?.Length ?? 0} bytes in memory, {written.Length} on disk" - ); - } - finally - { - if (File.Exists(path)) - { - File.Delete(path); - } - } - } - ) - .ConfigureAwait(false) - ); - - results.Add( - await TrySettingsCheckAsync( - "Ensure profile and scene collection", - async () => - { - // Asking for the one already active proves the check without disrupting OBS, - // since switching either of these reloads the whole configuration. - GetProfileListResponseData? profiles = await client - .Config.GetProfileListAsync(cancellationToken) - .ConfigureAwait(false); - GetSceneCollectionListResponseData? collections = await client - .Config.GetSceneCollectionListAsync(cancellationToken) - .ConfigureAwait(false); - - bool profileOk = await client - .Config.EnsureProfileActiveAsync( - profiles!.CurrentProfileName!, - cancellationToken - ) - .ConfigureAwait(false); - bool collectionOk = await client - .Config.EnsureSceneCollectionActiveAsync( - collections!.CurrentSceneCollectionName!, - cancellationToken - ) - .ConfigureAwait(false); - bool absent = await client - .Config.EnsureProfileActiveAsync( - "__no_such_profile__", - cancellationToken - ) - .ConfigureAwait(false); - - return ( - profileOk && collectionOk && !absent, - $"profile={profiles.CurrentProfileName}, collection={collections.CurrentSceneCollectionName}, absent reported {absent}" - ); - } - ) - .ConfigureAwait(false) - ); - - results.Add( - await TrySettingsCheckAsync( - "Media transport shorthands", - async () => - { - await client - .MediaInputs.PlayMediaAsync(inputName, cancellationToken) - .ConfigureAwait(false); - await client - .MediaInputs.PauseMediaAsync(inputName, cancellationToken) - .ConfigureAwait(false); - await client - .MediaInputs.RestartMediaAsync(inputName, cancellationToken) - .ConfigureAwait(false); - await client - .MediaInputs.StopMediaAsync(inputName, cancellationToken) - .ConfigureAwait(false); - - GetMediaInputStatusResponseData? status = await client - .MediaInputs.GetMediaInputStatusAsync( - new GetMediaInputStatusRequestData(inputName: inputName), - cancellationToken - ) - .ConfigureAwait(false); - - return (status is not null, $"state={status?.MediaState}"); - } - ) - .ConfigureAwait(false) - ); - - // Disabled: toggling the virtual camera takes down this OBS install. The fault is in - // the Stream Deck plugin (streamdeckpluginobs32.dll appears at the fault address and - // in every frame above it), not in OBS or in this library. Re-enable once that plugin - // is removed. - // results.Add( - // await TrySettingsCheckAsync( - // "Virtual camera toggle", - // async () => - // { - // bool before = await client - // .Outputs.IsVirtualCamActiveAsync(cancellationToken) - // .ConfigureAwait(false); - - // bool? turnedOn = await client - // .Outputs.SetVirtualCamActiveAndWaitAsync( - // !before, - // cancellationToken: cancellationToken - // ) - // .ConfigureAwait(false); - // bool observed = await client - // .Outputs.IsVirtualCamActiveAsync(cancellationToken) - // .ConfigureAwait(false); - - // // Put it back the way it was found. - // _ = await client - // .Outputs.SetVirtualCamActiveAndWaitAsync( - // before, - // cancellationToken: cancellationToken - // ) - // .ConfigureAwait(false); - // bool restored = await client - // .Outputs.IsVirtualCamActiveAsync(cancellationToken) - // .ConfigureAwait(false); - - // return ( - // turnedOn == !before && observed == !before && restored == before, - // $"{before} -> {observed} -> {restored}" - // ); - // } - // ) - // .ConfigureAwait(false) - // ); - - results.Add( - await TrySettingsCheckAsync( - "Integer fields round trip", - async () => - { - // The protocol calls every number "Number", so these fields used to - // arrive as double. Writing one and reading it back proves the - // retype survives the wire in both directions, which matters most - // for MessagePack, where an int and a float are different encodings. - long itemId = - await client - .SceneItems.FindSceneItemIdAsync( - sceneName, - inputName, - cancellationToken - ) - .ConfigureAwait(false) - ?? throw new InvalidOperationException( - $"'{inputName}' is not in '{sceneName}'." - ); - - GetSceneItemIndexResponseData originalIndex = await client - .SceneItems.GetSceneItemIndexAsync( - new GetSceneItemIndexRequestData( - sceneItemId: itemId, - sceneName: sceneName - ), - cancellationToken - ) - .ConfigureAwait(false); - - await client - .SceneItems.SetSceneItemIndexAsync( - new SetSceneItemIndexRequestData( - sceneItemId: itemId, - sceneItemIndex: 0, - sceneName: sceneName - ), - cancellationToken - ) - .ConfigureAwait(false); - - GetSceneItemIndexResponseData afterIndex = await client - .SceneItems.GetSceneItemIndexAsync( - new GetSceneItemIndexRequestData( - sceneItemId: itemId, - sceneName: sceneName - ), - cancellationToken - ) - .ConfigureAwait(false); - - await client - .SceneItems.SetSceneItemIndexAsync( - new SetSceneItemIndexRequestData( - sceneItemId: itemId, - sceneItemIndex: originalIndex.SceneItemIndex, - sceneName: sceneName - ), - cancellationToken - ) - .ConfigureAwait(false); - - // A negative value, since OBS accepts negative sync offsets and a - // sign error would otherwise go unnoticed. - GetInputAudioSyncOffsetResponseData originalOffset = await client - .Inputs.GetInputAudioSyncOffsetAsync( - new GetInputAudioSyncOffsetRequestData(inputName: inputName), - cancellationToken - ) - .ConfigureAwait(false); - - await client - .Inputs.SetInputAudioSyncOffsetAsync( - new SetInputAudioSyncOffsetRequestData( - inputAudioSyncOffset: -125, - inputName: inputName - ), - cancellationToken - ) - .ConfigureAwait(false); - - GetInputAudioSyncOffsetResponseData afterOffset = await client - .Inputs.GetInputAudioSyncOffsetAsync( - new GetInputAudioSyncOffsetRequestData(inputName: inputName), - cancellationToken - ) - .ConfigureAwait(false); - - await client - .Inputs.SetInputAudioSyncOffsetAsync( - new SetInputAudioSyncOffsetRequestData( - inputAudioSyncOffset: originalOffset.InputAudioSyncOffset, - inputName: inputName - ), - cancellationToken - ) - .ConfigureAwait(false); - - return ( - afterIndex.SceneItemIndex == 0 - && afterOffset.InputAudioSyncOffset == -125, - $"index {originalIndex.SceneItemIndex} -> {afterIndex.SceneItemIndex}, " - + $"syncOffset {originalOffset.InputAudioSyncOffset} -> {afterOffset.InputAudioSyncOffset}" - ); - } - ) - .ConfigureAwait(false) - ); - - results.Add( - await TrySettingsCheckAsync( - "Preview scene helpers", - async () => - { - // Preview only exists in Studio Mode, so turn it on for the check and - // put it back however it was found. - GetStudioModeEnabledResponseData studio = await client - .Ui.GetStudioModeEnabledAsync(cancellationToken) - .ConfigureAwait(false); - if (!studio.StudioModeEnabled) - { - await client - .Ui.SetStudioModeEnabledAsync(new(true), cancellationToken) - .ConfigureAwait(false); - } - - try - { - // Every switch waits for the event confirming it. OBS points - // Preview at the Program scene while enabling Studio Mode, and - // that lands after StudioModeStateChanged, so a switch that does - // not wait for its own confirmation gets silently undone. - await client - .Scenes.SwitchPreviewSceneAndWaitAsync( - sceneName, - cancellationToken: cancellationToken - ) - .ConfigureAwait(false); - GetCurrentPreviewSceneResponseData preview = await client - .Scenes.GetCurrentPreviewSceneAsync(cancellationToken) - .ConfigureAwait(false); - - // The plain overload, confirmed by waiting on the event directly. - Task back = - client.WaitForEventAsync( - e => - string.Equals( - e.EventData.SceneName, - originalScene, - StringComparison.Ordinal - ), - TimeSpan.FromSeconds(5), - cancellationToken - ); - await client - .Scenes.SwitchPreviewSceneAsync( - originalScene, - cancellationToken - ) - .ConfigureAwait(false); - _ = await back.ConfigureAwait(false); - - GetCurrentPreviewSceneResponseData restored = await client - .Scenes.GetCurrentPreviewSceneAsync(cancellationToken) - .ConfigureAwait(false); - - bool ok = - string.Equals( - preview.SceneName, - sceneName, - StringComparison.Ordinal - ) - && string.Equals( - restored.SceneName, - originalScene, - StringComparison.Ordinal - ); - - return ( - ok, - $"wanted {sceneName} got {preview.SceneName}, " - + $"then wanted {originalScene} got {restored.SceneName}" - ); - } - finally - { - if (!studio.StudioModeEnabled) - { - await client - .Ui.SetStudioModeEnabledAsync(new(false), cancellationToken) - .ConfigureAwait(false); - } - } - } - ) - .ConfigureAwait(false) - ); - - results.Add( - await TrySettingsCheckAsync( - "SourceExistsAsync", - async () => - { - bool present = await client - .Sources.SourceExistsAsync(inputName, cancellationToken) - .ConfigureAwait(false); - bool absent = await client - .Sources.SourceExistsAsync("__absent__", cancellationToken) - .ConfigureAwait(false); - - return (present && !absent, $"present={present}, absent={absent}"); - } - ) - .ConfigureAwait(false) - ); - - results.Add( - await TrySettingsCheckAsync( - "SetInputMutesAsync", - async () => - { - GetInputMuteResponseData before = await client - .Inputs.GetInputMuteAsync( - new(inputName: inputName), - cancellationToken - ) - .ConfigureAwait(false); - - // One real input beside one that does not exist, so the returned - // results have to show a success next to a failure. - BatchResults muteResults = await client - .Inputs.SetInputMutesAsync( - [(inputName, !before.InputMuted), ("__absent__", true)], - cancellationToken - ) - .ConfigureAwait(false); - - GetInputMuteResponseData after = await client - .Inputs.GetInputMuteAsync( - new(inputName: inputName), - cancellationToken - ) - .ConfigureAwait(false); - - await client - .Inputs.SetInputMutesAsync( - [(inputName, before.InputMuted)], - cancellationToken - ) - .ConfigureAwait(false); - - bool ok = - muteResults.Count == 2 - && muteResults[0].RequestStatus.Result - && !muteResults[1].RequestStatus.Result - && after.InputMuted == !before.InputMuted; - - return ( - ok, - $"{muteResults.Count} results, muted {before.InputMuted} -> {after.InputMuted}" - ); - } - ) - .ConfigureAwait(false) - ); - - results.Add( - await TrySettingsCheckAsync( - "Transition settings read", - async () => - { - GetCurrentSceneTransitionResponseData current = await client - .Transitions.GetCurrentSceneTransitionAsync(cancellationToken) - .ConfigureAwait(false); - // The typed Get*SettingsAsync helpers deserialize into a settings - // record; the generated request is the way to read the raw JSON. - // A transition with nothing to configure, such as Fade, legitimately - // reports no settings, so the name and kind are what is asserted. - JsonElement? settings = current.TransitionSettings; - - return ( - !string.IsNullOrEmpty(current.TransitionName) - && !string.IsNullOrEmpty(current.TransitionKind), - $"{current.TransitionName} ({current.TransitionKind}), " - + $"settings {(settings is null ? "none" : "present")}" - ); - } - ) - .ConfigureAwait(false) - ); - - results.Add( - await TrySettingsCheckAsync( - "Canvas screenshot helper", - async () => - { - byte[]? bytes = await client - .Sources.GetSourceScreenshotOnCanvasBytesAsync( - sceneName, - "png", - cancellationToken: cancellationToken - ) - .ConfigureAwait(false); - - bool png = - bytes is { Length: > 8 } - && bytes[0] == 0x89 - && bytes[1] == 0x50 - && bytes[2] == 0x4E - && bytes[3] == 0x47; - - return (png, $"{bytes?.Length ?? 0} bytes at canvas size"); - } - ) - .ConfigureAwait(false) - ); - - results.Add( - await TrySettingsCheckAsync( - "Parallel batch, verdict without attribution", - async () => - { - // A parallel batch of writes is the case Parallel is actually good - // for: OBS mispairs the rows, but a verdict over all of them does not - // depend on which row is which. - ObsBatchBuilder par = new(); - _ = par.Inputs.SetInputMute( - new SetInputMuteRequestData(inputName: inputName, inputMuted: true) - ); - _ = par.Inputs.SetInputMute( - new SetInputMuteRequestData(inputName: inputName, inputMuted: false) - ); - - BatchResults ok = await client - .CallBatchAsync( - par, - executionType: RequestBatchExecutionType.Parallel, - haltOnFailure: false, - cancellationToken: cancellationToken - ) - .ConfigureAwait(false); - - // Same again with one request that cannot succeed, so the count of - // failures is checked as well as the all-succeeded verdict. - ObsBatchBuilder mixed = new(); - _ = mixed.Inputs.SetInputMute( - new SetInputMuteRequestData(inputName: inputName, inputMuted: false) - ); - _ = mixed.Inputs.SetInputMute( - new SetInputMuteRequestData( - inputName: "__absent__", - inputMuted: true - ) - ); - - BatchResults partial = await client - .CallBatchAsync( - mixed, - executionType: RequestBatchExecutionType.Parallel, - haltOnFailure: false, - cancellationToken: cancellationToken - ) - .ConfigureAwait(false); - - int failures = partial.GetFailures().Count(); - - return ( - ok.AllSucceeded() && !partial.AllSucceeded() && failures == 1, - $"all-ok verdict {ok.AllSucceeded()}, mixed verdict " - + $"{partial.AllSucceeded()} with {failures} failure(s)" - ); - } - ) - .ConfigureAwait(false) - ); - - results.Add( - await TrySettingsCheckAsync( - "Concurrent requests keep their own results", - async () => - { - // The answer to "how do I run things in parallel" once a parallel - // batch is ruled out for reads. The client multiplexes on request id. - Task version = client.General.GetVersionAsync( - cancellationToken - ); - Task video = - client.Config.GetVideoSettingsAsync(cancellationToken); - Task itemsHere = - client.SceneItems.GetSceneItemListAsync( - new GetSceneItemListRequestData(sceneName: sceneName), - cancellationToken - ); - Task itemsThere = - client.SceneItems.GetSceneItemListAsync( - new GetSceneItemListRequestData(sceneName: originalScene), - cancellationToken - ); - - await Task.WhenAll(version, video, itemsHere, itemsThere) - .ConfigureAwait(false); - - // Each answer has to match what the same request returns on its own. - GetVersionResponseData serialVersion = await client - .General.GetVersionAsync(cancellationToken) - .ConfigureAwait(false); - GetSceneItemListResponseData serialHere = await client - .SceneItems.GetSceneItemListAsync( - new GetSceneItemListRequestData(sceneName: sceneName), - cancellationToken - ) - .ConfigureAwait(false); - - bool ok = - string.Equals( - version.Result.ObsVersion, - serialVersion.ObsVersion, - StringComparison.Ordinal - ) - && video.Result.FpsNumerator > 0 - && itemsHere.Result.SceneItems?.Count - == serialHere.SceneItems?.Count; - - return ( - ok, - $"v={version.Result.ObsVersion}, fps={video.Result.FpsNumerator}, " - + $"items {itemsHere.Result.SceneItems?.Count} here vs " - + $"{itemsThere.Result.SceneItems?.Count} there" - ); - } - ) - .ConfigureAwait(false) - ); - - results.Add( - await TrySettingsCheckAsync( - "Low level Add and raw results", - async () => - { - // Hand rolled batch: Add covers anything the generated methods do not, - // and the raw payload helpers read it back. - ObsBatchBuilder raw = new(); - _ = raw.Add("GetVersion"); - _ = raw.Add("GetStats"); - - BatchResults rawResults = await client - .CallBatchAsync( - raw, - executionType: RequestBatchExecutionType.SerialRealtime, - cancellationToken: cancellationToken - ) - .ConfigureAwait(false); - - GetVersionResponseData? v = rawResults - .Raw[0] - .GetData(); - - // And the same request without any batch at all. - GetVersionResponseData? direct = await client - .CallAsync( - "GetVersion", - null, - cancellationToken: cancellationToken - ) - .ConfigureAwait(false); - - bool ok = - rawResults.Count == 2 - && v is not null - && direct is not null - && string.Equals( - v.ObsVersion, - direct.ObsVersion, - StringComparison.Ordinal - ); - - return ( - ok, - $"raw batch {rawResults.Count}, CallAsync {direct?.ObsVersion}" - ); - } - ) - .ConfigureAwait(false) - ); - - results.Add( - await TrySettingsCheckAsync( - "Scene item list reindexed", - async () => - { - // Reindexing asks OBS for the basic scene item list, a different shape - // from every other sceneItems array, so the event needs its own stub. - GetSceneItemListResponseData items = await client - .SceneItems.GetSceneItemListAsync( - new GetSceneItemListRequestData(sceneName: sceneName), - cancellationToken - ) - .ConfigureAwait(false); - if (items.SceneItems.Count == 0) - { - return (false, "no scene items to reindex"); - } - - long id = items.SceneItems[0].SceneItemId; - int index = items.SceneItems[0].SceneItemIndex; - - Task reindexed = - client.WaitForEventAsync( - timeout: TimeSpan.FromSeconds(5), - cancellationToken: cancellationToken - ); - - await client - .SceneItems.SetSceneItemIndexAsync( - new SetSceneItemIndexRequestData( - sceneItemId: id, - sceneItemIndex: index, - sceneName: sceneName - ), - cancellationToken - ) - .ConfigureAwait(false); - - SceneItemListReindexedEventArgs args = await reindexed.ConfigureAwait( - false - ); - - SceneItemOrderStub? moved = args.EventData.SceneItems.Find(i => - i.SceneItemId == id - ); - - return ( - moved is not null - && string.Equals( - args.EventData.SceneName, - sceneName, - StringComparison.Ordinal - ), - $"{args.EventData.SceneItems.Count} item(s) reindexed, " - + $"item {id} at index {moved?.SceneItemIndex}" - ); - } - ) - .ConfigureAwait(false) - ); - - results.Add( - await TrySettingsCheckAsync( - "Input volume meters", - async () => - { - // High rate event with its own stub. It was read as an InputStub and - // failed on the kind fields it never sends, so it never fired at all. - // A collection with no audio input reports no meters at all, which - // says nothing about whether the payload reads correctly. - GetInputListResponseData present = await client - .Inputs.GetInputListAsync(new(), cancellationToken) - .ConfigureAwait(false); - string? seeded = null; - if ( - !present.Inputs.Exists(i => - i.InputKind.Contains("wasapi", StringComparison.Ordinal) - ) - ) - { - // OBS meters only the inputs it considers active, which means - // the ones in the program scene. Anywhere else reports nothing. - GetSceneListResponseData live = await client - .Scenes.GetSceneListAsync(new(), cancellationToken) - .ConfigureAwait(false); - seeded = $"__obsws_meter_{Guid.NewGuid():N}"[..22]; - await client - .Inputs.CreateInputAsync( - new( - inputName: seeded, - inputKind: kinds.AudioCapture!, - sceneName: live.CurrentProgramSceneName ?? sceneName - ), - cancellationToken - ) - .ConfigureAwait(false); - } - - EventSubscription? before = client.CurrentEventSubscriptions; - await client - .ReidentifyAsync( - (uint)( - (before ?? EventSubscription.All) - | EventSubscription.InputVolumeMeters - ), - cancellationToken: cancellationToken - ) - .ConfigureAwait(false); - - try - { - InputVolumeMetersEventArgs meters = await client - .WaitForEventAsync( - timeout: TimeSpan.FromSeconds(5), - cancellationToken: cancellationToken - ) - .ConfigureAwait(false); - - InputVolumeMeterStub? first = - meters.EventData.Inputs.Count > 0 - ? meters.EventData.Inputs[0] - : null; - - // An input with no audio channels reports an empty level list, so - // the levels are checked where they exist rather than required. - bool levelsWellFormed = meters.EventData.Inputs.TrueForAll(i => - i.InputLevelsMul.TrueForAll(channel => channel.Count == 3) - ); - int channels = meters.EventData.Inputs.Sum(i => - i.InputLevelsMul.Count - ); - - bool ok = - first is not null - && !string.IsNullOrEmpty(first.InputName) - && Guid.TryParse(first.InputUuid, out _) - && levelsWellFormed; - - return ( - ok, - $"{meters.EventData.Inputs.Count} input(s), first '{first?.InputName}', " - + $"{channels} channel(s) total, three levels each = " - + $"{levelsWellFormed}" - ); - } - finally - { - if (before is not null) - { - await client - .ReidentifyAsync( - (uint)before.Value, - cancellationToken: CancellationToken.None - ) - .ConfigureAwait(false); - } - - if (seeded is not null) - { - await client - .Inputs.RemoveInputAsync( - new(inputName: seeded), - CancellationToken.None - ) - .ConfigureAwait(false); - } - } - } - ) - .ConfigureAwait(false) - ); - - results.Add( - await TrySettingsCheckAsync( - "Handles address by name and by uuid", - async () => - { - // The point of a uuid handle is that it survives a rename. Both forms - // have to reach the same scene for that to be worth anything. - SceneHandle byName = sceneName; - SceneOperations resolved = await client - .Scene(byName) - .ResolveAsync(cancellationToken) - .ConfigureAwait(false); - SceneHandle byUuid = resolved.Handle; - - GetSceneItemListResponseData viaName = await client - .Scene(byName) - .GetItemListAsync(cancellationToken) - .ConfigureAwait(false); - GetSceneItemListResponseData viaUuid = await client - .Scene(byUuid) - .GetItemListAsync(cancellationToken) - .ConfigureAwait(false); - - string renamed = sceneName + "_renamed"; - await client - .Scene(byUuid) - .SetNameAsync(renamed, cancellationToken) - .ConfigureAwait(false); - - bool uuidStillWorks; - try - { - _ = await client - .Scene(byUuid) - .GetItemListAsync(cancellationToken) - .ConfigureAwait(false); - uuidStillWorks = true; - } - catch (ObsWebSocketRequestException) - { - uuidStillWorks = false; - } - - bool nameNowMisses; - try - { - _ = await client - .Scene(byName) - .GetItemListAsync(cancellationToken) - .ConfigureAwait(false); - nameNowMisses = false; - } - catch (ObsWebSocketRequestException) - { - nameNowMisses = true; - } - - await client - .Scene(byUuid) - .SetNameAsync(sceneName, CancellationToken.None) - .ConfigureAwait(false); - - return ( - byUuid.IsResolved - && viaName.SceneItems.Count == viaUuid.SceneItems.Count - && uuidStillWorks - && nameNowMisses, - $"resolved to {byUuid.Uuid}, both read {viaUuid.SceneItems.Count} " - + $"item(s); after a rename the uuid still resolves " - + $"({uuidStillWorks}) and the name does not ({nameNowMisses})" - ); - } - ) - .ConfigureAwait(false) - ); - - results.Add( - await TrySettingsCheckAsync( - "A miss says what does exist", - async () => - { - // OBS answers ResourceNotFound and the name you already gave it. The - // list is in hand from the lookup, so the client can do better. - ObsWebSocketResourceNotFoundException ex = - await ExpectThrowAsync(() => - client - .Scenes.ResolveAsync( - "__obsws_no_such_scene", - cancellationToken - ) - .AsTask() - ) - .ConfigureAwait(false); - - return ( - ex.Available.Count > 0 - && ex.Message.Contains( - "__obsws_no_such_scene", - StringComparison.Ordinal - ) - && ex.Available.Contains(sceneName, StringComparer.Ordinal), - $"named {ex.Available.Count} scene(s), including the one the run made" - ); - } - ) - .ConfigureAwait(false) - ); - - results.Add( - await TrySettingsCheckAsync( - "A scene item resolves by source name", - async () => - { - // The one lookup that is not a convenience: OBS addresses scene items - // by a number nothing else reports. - SceneItemOperations item = await client - .Scene(sceneName) - .ItemAsync(inputName, cancellationToken: cancellationToken) - .ConfigureAwait(false); - - GetSceneItemEnabledResponseData enabled = await item.GetEnabledAsync( - cancellationToken - ) - .ConfigureAwait(false); - - // Navigating back up reaches the scene the item is in. - GetSceneItemListResponseData siblings = await item - .Scene.GetItemListAsync(cancellationToken) - .ConfigureAwait(false); - - return ( - item.Handle.SceneItemId >= 0 && siblings.SceneItems.Count > 0, - $"'{inputName}' is item {item.Handle.SceneItemId}, enabled " - + $"{enabled.SceneItemEnabled}, among {siblings.SceneItems.Count} " - + "in its scene" - ); - } - ) - .ConfigureAwait(false) - ); - - results.Add( - await TrySettingsCheckAsync( - "Canvases category", - async () => - { - // The only request in its category. Its array carries no item type in - // the protocol definition, so the stub is taken from the request - // handler and has to be checked against a real OBS on both transports. - GetCanvasListResponseData canvases = await client - .Canvases.GetCanvasListAsync(cancellationToken) - .ConfigureAwait(false); - - CanvasStub? main = canvases.Canvases.Find(c => c.CanvasFlags.Main); - bool ok = - canvases.Canvases.Count > 0 - && main is not null - && !string.IsNullOrEmpty(main.CanvasName) - && Guid.TryParse(main.CanvasUuid, out _) - && main.CanvasVideoSettings.BaseWidth > 0 - && main.CanvasVideoSettings.BaseHeight > 0 - && main.CanvasVideoSettings.FpsNumerator > 0; - - return ( - ok, - $"{canvases.Canvases.Count} canvas(es), main '{main?.CanvasName}' " - + $"{main?.CanvasVideoSettings.BaseWidth}x{main?.CanvasVideoSettings.BaseHeight} " - + $"@ {main?.CanvasVideoSettings.FpsNumerator}/{main?.CanvasVideoSettings.FpsDenominator}, " - + $"flags MAIN={main?.CanvasFlags.Main} MIX_AUDIO={main?.CanvasFlags.MixAudio}" - ); - } - ) - .ConfigureAwait(false) - ); - - results.Add( - await TrySettingsCheckAsync( - "Request with neither payload", - async () => - { - // The one generated shape nothing else exercises: no request data and - // no response. Pointing Preview at the scene already in Program makes - // the transition a no-op, so the check is safe to run. - GetStudioModeEnabledResponseData studio = await client - .Ui.GetStudioModeEnabledAsync(cancellationToken) - .ConfigureAwait(false); - if (!studio.StudioModeEnabled) - { - await client - .Ui.SetStudioModeEnabledAsync(new(true), cancellationToken) - .ConfigureAwait(false); - } - - try - { - GetSceneListResponseData before = await client - .Scenes.GetSceneListAsync(new(), cancellationToken) - .ConfigureAwait(false); - string program = before.CurrentProgramSceneName!; - - // Plain switch, not the waiting variant: OBS raises no - // CurrentPreviewSceneChanged when the preview is already that - // scene, which enabling Studio Mode has just made it. - await client - .Scenes.SwitchPreviewSceneAsync(program, cancellationToken) - .ConfigureAwait(false); - - await client - .Transitions.TriggerStudioModeTransitionAsync(cancellationToken) - .ConfigureAwait(false); - - GetSceneListResponseData after = await client - .Scenes.GetSceneListAsync(new(), cancellationToken) - .ConfigureAwait(false); - - return ( - string.Equals( - after.CurrentProgramSceneName, - program, - StringComparison.Ordinal - ), - $"transitioned, program still '{after.CurrentProgramSceneName}'" - ); - } - finally - { - if (!studio.StudioModeEnabled) - { - await client - .Ui.SetStudioModeEnabledAsync(new(false), cancellationToken) - .ConfigureAwait(false); - } - } - } - ) - .ConfigureAwait(false) - ); - - results.Add( - await TrySettingsCheckAsync( - "Nested request object", - async () => - { - // keyModifiers is the one generated nested record, a distinct shape - // from the flat request payloads. F13 is not bound by default, so the - // press does nothing. - await client - .General.TriggerHotkeyByKeySequenceAsync( - new TriggerHotkeyByKeySequenceRequestData( - keyId: "OBS_KEY_F13", - keyModifiers: new TriggerHotkeyByKeySequenceRequestData_KeyModifiers( - shift: false, - control: true, - alt: false, - command: false - ) - ), - cancellationToken - ) - .ConfigureAwait(false); - - return (true, "nested keyModifiers accepted"); - } - ) - .ConfigureAwait(false) - ); - - results.Add( - await TrySettingsCheckAsync( - "Remaining stub types", - async () => - { - // Monitor, output and transition stubs are generated the same way as - // the scene and input ones, so one read each covers the shape. - GetMonitorListResponseData monitors = await client - .Ui.GetMonitorListAsync(cancellationToken) - .ConfigureAwait(false); - GetSceneTransitionListResponseData transitions = await client - .Transitions.GetSceneTransitionListAsync(cancellationToken) - .ConfigureAwait(false); - bool ok = - monitors.Monitors.Count > 0 - && transitions.Transitions.Count > 0 - && outputs.Outputs.Count > 0 - && monitors.Monitors[0].MonitorWidth > 0 - && !string.IsNullOrEmpty(transitions.Transitions[0].TransitionName) - && !string.IsNullOrEmpty(outputs.Outputs[0].OutputName); - - return ( - ok, - $"{monitors.Monitors.Count} monitor(s) first {monitors.Monitors[0].MonitorWidth}px, " - + $"{transitions.Transitions.Count} transition(s), {outputs.Outputs.Count} output(s)" - ); - } - ) - .ConfigureAwait(false) - ); - - results.Add( - await TrySettingsCheckAsync( - "Classic handler and subscriptions", - async () => - { - // The stream path is covered above; this is the += path, plus the - // negotiated subscription flags the client reports. - TaskCompletionSource seen = new( - TaskCreationOptions.RunContinuationsAsynchronously - ); - string? observed = null; - void Handler(object? sender, SceneCreatedEventArgs e) - { - observed = e.EventData.SceneName; - _ = seen.TrySetResult(); - } - - client.Scenes.SceneCreated += Handler; - string probe = $"__obsws_handler_{Guid.NewGuid():N}"[..24]; - try - { - await client - .Scenes.CreateSceneAsync( - new CreateSceneRequestData(sceneName: probe), - cancellationToken - ) - .ConfigureAwait(false); - await seen - .Task.WaitAsync(TimeSpan.FromSeconds(5), cancellationToken) - .ConfigureAwait(false); - } - finally - { - client.Scenes.SceneCreated -= Handler; - await client - .Scenes.RemoveSceneAsync( - new RemoveSceneRequestData(sceneName: probe), - CancellationToken.None - ) - .ConfigureAwait(false); - } - - EventSubscription? subs = client.CurrentEventSubscriptions; - - return ( - string.Equals(observed, probe, StringComparison.Ordinal) - && subs is not null - && subs.Value.HasFlag(EventSubscription.Scenes), - $"handler saw '{observed}', subscriptions {subs}" - ); - } - ) - .ConfigureAwait(false) - ); - - results.Add( - await TrySettingsCheckAsync( - "Every way of sending a request", - async () => - { - // One request, reached six ways, so no path is left unexercised. - // 1. The generated request on its category group. - GetVersionResponseData viaGroup = await client - .General.GetVersionAsync(cancellationToken) - .ConfigureAwait(false); - - // 2. The low level typed call, for a reference type response. - GetVersionResponseData? viaCall = await client - .CallAsync( - "GetVersion", - null, - cancellationToken: cancellationToken - ) - .ConfigureAwait(false); - - // 3. The low level untyped call, for a value type response. - JsonElement? viaValue = await client - .CallAsyncValue( - "GetVersion", - null, - cancellationToken: cancellationToken - ) - .ConfigureAwait(false); - string? viaRawJson = viaValue?.GetProperty("obsVersion").GetString(); - - // 4. A hand built JsonElement as the request payload. - using JsonDocument requestBody = JsonDocument.Parse( - $$"""{"sceneName":"{{sceneName}}"}""" - ); - JsonElement? viaJsonBody = await client - .CallAsyncValue( - "GetSceneItemList", - requestBody.RootElement, - cancellationToken: cancellationToken - ) - .ConfigureAwait(false); - int itemsViaJsonBody = - viaJsonBody?.GetProperty("sceneItems").GetArrayLength() ?? -1; - - // 5. The typed batch builder. - ObsBatchBuilder builder = new(); - BatchRef batched = builder.General.GetVersion(); - BatchResults built = await client - .CallBatchAsync( - builder, - executionType: RequestBatchExecutionType.SerialRealtime, - cancellationToken: cancellationToken - ) - .ConfigureAwait(false); - string viaBatch = built.Get(batched).ObsVersion; - - // 6. A hand rolled batch item, with a JsonElement payload. - using JsonDocument batchBody = JsonDocument.Parse( - $$"""{"sceneName":"{{sceneName}}"}""" - ); - List> viaRawBatch = await client - .CallBatchAsync( - [ - new BatchRequestItem("GetVersion", null), - new BatchRequestItem( - "GetSceneItemList", - batchBody.RootElement - ), - ], - executionType: RequestBatchExecutionType.SerialRealtime, - cancellationToken: cancellationToken - ) - .ConfigureAwait(false); - string? viaRawBatchVersion = viaRawBatch[0] - .GetData() - ?.ObsVersion; - - // 7. A consumer's own JsonSerializerContext, so a type this library - // has never heard of is sent without hand building a JsonElement. - JsonElement? viaConsumerContext = await client - .CallAsyncValue( - "GetSceneItemList", - new ConsumerSceneRequest(sceneName), - ExampleRequestContext.Default.ConsumerSceneRequest, - cancellationToken: cancellationToken - ) - .ConfigureAwait(false); - int itemsViaContext = - viaConsumerContext?.GetProperty("sceneItems").GetArrayLength() - ?? -1; - - // And the one shape that is not supported, asserted as unsupported: - // an anonymous object has no metadata in the serializer context. - string anonymous; - try - { - _ = await client - .CallAsyncValue( - "GetSceneItemList", - new { sceneName }, - cancellationToken: cancellationToken - ) - .ConfigureAwait(false); - anonymous = "unexpectedly accepted"; - } - catch (ObsWebSocketSerializationException) - { - anonymous = "refused"; - } - - string expected = viaGroup.ObsVersion; - bool allAgree = - viaCall?.ObsVersion == expected - && viaRawJson == expected - && viaBatch == expected - && viaRawBatchVersion == expected - && itemsViaJsonBody >= 0 - && itemsViaContext == itemsViaJsonBody - && anonymous == "refused"; - - return ( - allAgree, - $"seven paths agree on {expected}, JsonElement body and consumer " - + $"context both read {itemsViaJsonBody} item(s), anonymous " - + $"object {anonymous}" - ); - } - ) - .ConfigureAwait(false) - ); - - results.Add( - await TrySettingsCheckAsync( - "Typed exception on a rejected request", - async () => - { - try - { - _ = await client - .SceneItems.GetSceneItemListAsync( - new GetSceneItemListRequestData( - sceneName: "__no_such_scene__" - ), - cancellationToken - ) - .ConfigureAwait(false); - return (false, "no exception"); - } - catch (ObsWebSocketRequestException ex) - { - return ( - ex.StatusCode == RequestStatusCode.ResourceNotFound - && ex.RequestType == "GetSceneItemList", - $"{ex.RequestType} code {(int?)ex.StatusCode}" - ); - } - } - ) - .ConfigureAwait(false) - ); - - results.Add( - await TrySettingsCheckAsync( - "Output state helpers", - async () => - { - bool recording = await client - .Record.IsRecordActiveAsync(cancellationToken) - .ConfigureAwait(false); - bool streaming = await client - .Stream.IsStreamActiveAsync(cancellationToken) - .ConfigureAwait(false); - bool virtualCam = await client - .Outputs.IsVirtualCamActiveAsync(cancellationToken) - .ConfigureAwait(false); - - // Each helper has to agree with the request it wraps. - GetRecordStatusResponseData? recordStatus = await client - .Record.GetRecordStatusAsync(cancellationToken) - .ConfigureAwait(false); - GetStreamStatusResponseData? streamStatus = await client - .Stream.GetStreamStatusAsync(cancellationToken) - .ConfigureAwait(false); - GetVirtualCamStatusResponseData? camStatus = await client - .Outputs.GetVirtualCamStatusAsync(cancellationToken) - .ConfigureAwait(false); - - bool agrees = - recording == recordStatus?.OutputActive - && streaming == streamStatus?.OutputActive - && virtualCam == camStatus?.OutputActive; - - return ( - agrees, - $"record={recording}, stream={streaming}, virtualCam={virtualCam}, agrees={agrees}" - ); - } - ) - .ConfigureAwait(false) - ); - } - finally - { - // Always put OBS back the way it was found. - try - { - if (!string.IsNullOrEmpty(originalScene)) - { - await client - .Scenes.SwitchProgramSceneAsync( - originalScene, - cancellationToken: CancellationToken.None - ) - .ConfigureAwait(false); - } - - if (inputCreated) - { - await client - .Inputs.RemoveInputAsync( - new RemoveInputRequestData(inputName: inputName), - CancellationToken.None - ) - .ConfigureAwait(false); - } - - if (sceneCreated) - { - await client - .Scenes.RemoveSceneAsync( - new RemoveSceneRequestData(sceneName: sceneName), - CancellationToken.None - ) - .ConfigureAwait(false); - } - } - catch (ObsWebSocketException ex) - { - results.Add(("Cleanup", false, ex.Message)); - } - } - - return results; - } - - private static async Task<(string Label, bool Pass, string Detail)> TrySettingsCheckAsync( - string label, - Func> action - ) - { - try - { - (bool pass, string detail) = await action().ConfigureAwait(false); - return (label, pass, detail); - } - catch (Exception ex) - { - string msg = ex.Message.Length > 100 ? ex.Message[..100] : ex.Message; - return (label, false, msg); - } - } - - private IWebSocketMessageSerializer CreateSerializer(SerializationFormat format) => - format switch - { - SerializationFormat.MsgPack => new MsgPackMessageSerializer( - _loggerFactory.CreateLogger() - ), - _ => new JsonMessageSerializer(_loggerFactory.CreateLogger()), - }; - - private ( - bool Observed, - bool Valid, - int ExtensionBagCount, - int ExtensionEntryCount - ) ValidateStubExtensionData( - GetSceneListResponseData? scenes, - GetInputListResponseData? inputs, - string? firstInputName, - SerializationFormat format - ) - { - List?> extensionBags = - [ - .. (scenes?.Scenes ?? []).Select(scene => scene.ExtensionData), - .. (inputs?.Inputs ?? []).Select(input => input.ExtensionData), - ]; - - int extensionBagCount = extensionBags.Count(bag => bag is { Count: > 0 }); - int extensionEntryCount = extensionBags - .Where(bag => bag is { Count: > 0 }) - .Sum(bag => bag!.Count); - - bool valid = true; - foreach ( - Dictionary? bag in extensionBags.Where(bag => - bag is { Count: > 0 } - ) - ) - { - foreach ((string _, JsonElement value) in bag!) - { - if (!IsValidExtensionDataValue(value)) - { - valid = false; - break; - } - } - - if (!valid) - { - break; - } - } - - bool observed = extensionBagCount > 0; - if (observed) - { - _logger.LogInformation( - "[{Format}] Stub ExtensionData validated: {BagCount} bag(s), {EntryCount} entries.", - format, - extensionBagCount, - extensionEntryCount - ); - } - else - { - _logger.LogWarning( - "[{Format}] Stub ExtensionData was not present in GetSceneList/GetInputList responses for input '{InputName}'.", - format, - firstInputName ?? "N/A" - ); - } - - return (observed, valid, extensionBagCount, extensionEntryCount); - } - - private static bool IsValidExtensionDataValue(JsonElement value) - { - try - { - if (value.ValueKind == JsonValueKind.Undefined) - { - return false; - } - - _ = value.GetRawText(); - return true; - } - catch (Exception) - { - return false; - } - } - - private static bool TryFindCustomEventPayloadByTestId( - JsonElement source, - string testId, - out JsonElement payload - ) => TryFindCustomEventPayloadByTestIdCore(source, testId, depth: 0, out payload); - - private static bool TryFindCustomEventPayloadByTestIdCore( - JsonElement source, - string testId, - int depth, - out JsonElement payload - ) - { - payload = default; - if (depth > 8) - { - return false; - } - - switch (source.ValueKind) - { - case JsonValueKind.Object: - { - if ( - source.TryGetProperty("testId", out JsonElement idProperty) - && idProperty.ValueKind == JsonValueKind.String - && string.Equals(idProperty.GetString(), testId, StringComparison.Ordinal) - ) - { - payload = source.Clone(); - return true; - } - - foreach (JsonProperty property in source.EnumerateObject()) - { - if ( - TryFindCustomEventPayloadByTestIdCore( - property.Value, - testId, - depth + 1, - out payload - ) - ) - { - return true; - } - } - - return false; - } - - case JsonValueKind.Array: - { - foreach (JsonElement element in source.EnumerateArray()) - { - if ( - TryFindCustomEventPayloadByTestIdCore( - element, - testId, - depth + 1, - out payload - ) - ) - { - return true; - } - } - - return false; - } - - case JsonValueKind.String: - { - string? rawString = source.GetString(); - if (string.IsNullOrWhiteSpace(rawString)) - { - return false; - } - - string trimmed = rawString.Trim(); - if (!trimmed.StartsWith('{') && !trimmed.StartsWith('[')) - { - return false; - } - - try - { - using JsonDocument parsed = JsonDocument.Parse(trimmed); - return TryFindCustomEventPayloadByTestIdCore( - parsed.RootElement, - testId, - depth + 1, - out payload - ); - } - catch (JsonException) - { - return false; - } - } - - default: - return false; - } - } - - private ObsWebSocketClientOptions CloneOptionsForFormat(SerializationFormat format) => - new() - { - ServerUri = _baseOptions.ServerUri, - Password = _baseOptions.Password, - EventSubscriptions = _baseOptions.EventSubscriptions, - HandshakeTimeoutMs = _baseOptions.HandshakeTimeoutMs, - RequestTimeoutMs = _baseOptions.RequestTimeoutMs, - Format = format, - AutoReconnectEnabled = false, - InitialReconnectDelayMs = _baseOptions.InitialReconnectDelayMs, - MaxReconnectAttempts = _baseOptions.MaxReconnectAttempts, - ReconnectBackoffMultiplier = _baseOptions.ReconnectBackoffMultiplier, - MaxReconnectDelayMs = _baseOptions.MaxReconnectDelayMs, - }; - - // --- Helper to find Scene Item ID --- - private async Task GetSceneItemIdAsync( - string sceneName, - string sourceName, - CancellationToken cancellationToken - ) - { - GetSceneItemIdResponseData? response = await _obsClient.SceneItems.GetSceneItemIdAsync( - new GetSceneItemIdRequestData { SceneName = sceneName, SourceName = sourceName }, - cancellationToken: cancellationToken - ); - - return response?.SceneItemId == null - ? throw new SceneItemNotFoundException( - $"Source '{sourceName}' not found in scene '{sceneName}'." - ) - : response.SceneItemId; - } - - /// - /// Shows what resolving a handle costs, what it buys, and what a miss reports. - /// - /// - /// The rest of the interactive commands address things by name, which is right for a name the - /// operator just typed. This one is the counterpart: it turns a name into a uuid once, and - /// everything after that survives a rename in OBS. - /// - private async Task ResolveDemoAsync( - string sceneName, - string? sourceName, - CancellationToken cancellationToken - ) - { - // One round trip. The protocol has no "uuid of the scene called X" request, so this is - // GetSceneList and a scan. - SceneOperations resolved = await _obsClient - .Scene(sceneName) - .ResolveAsync(cancellationToken); - - RenderKeyValueTable( - "Resolved scene", - [ - ("Given", sceneName), - ("UUID", resolved.Handle.Uuid ?? "N/A"), - ("Survives a rename", resolved.Handle.IsResolved ? "yes" : "no"), - ] - ); - - // Held by uuid, so this reads the same scene even if it is renamed between the two calls. - GetSceneItemListResponseData items = await resolved.GetItemListAsync(cancellationToken); - UiInfo($"'{sceneName}' holds {items.SceneItems.Count} scene item(s)."); - - if (sourceName is not null) - { - // The one lookup that is not a convenience: OBS addresses scene items by a number that - // only GetSceneItemId reports, so an item known by source name cannot be acted on until - // it has been resolved. The type system says so: ItemAsync returns the actable type. - SceneItemOperations item = await resolved.ItemAsync( - sourceName, - cancellationToken: cancellationToken - ); - GetSceneItemEnabledResponseData enabled = await item.GetEnabledAsync(cancellationToken); - - RenderKeyValueTable( - "Resolved scene item", - [ - ("Source", sourceName), - ("Item id", item.Handle.SceneItemId.ToString(CultureInfo.InvariantCulture)), - ("Enabled", enabled.SceneItemEnabled ? "yes" : "no"), - ("Back up to", item.Scene.Handle.Uuid ?? item.Scene.Handle.Name ?? "N/A"), - ] - ); - } - - // The lookup already fetched the list, so a miss can name what - // does exist. OBS itself can only answer ResourceNotFound and the name you gave it. - try - { - _ = await _obsClient.Scenes.ResolveAsync( - sceneName + "__no_such_scene", - cancellationToken - ); - } - catch (ObsWebSocketResourceNotFoundException ex) - { - UiInfo($"A miss reports what exists: {ex.Message}"); - } - } - - private async Task GetAllSettingsTypesAsync(CancellationToken cancellationToken) - { - _logger.LogInformation("Fetching all settings type schemas from OBS..."); - - await DumpKindDefaultSettingsAsync( - "Filter Kind Defaults", - async ct => - { - GetSourceFilterKindListResponseData? r = - await _obsClient.Filters.GetSourceFilterKindListAsync(cancellationToken: ct); - return r?.SourceFilterKinds ?? []; - }, - async (kind, ct) => - { - GetSourceFilterDefaultSettingsResponseData? r = - await _obsClient.Filters.GetSourceFilterDefaultSettingsAsync( - new GetSourceFilterDefaultSettingsRequestData(kind), - cancellationToken: ct - ); - return r?.DefaultFilterSettings; - }, - cancellationToken - ); - - await DumpKindDefaultSettingsAsync( - "Input Kind Defaults", - async ct => - { - GetInputKindListResponseData? r = await _obsClient.Inputs.GetInputKindListAsync( - new GetInputKindListRequestData(unversioned: false), - cancellationToken: ct - ); - return r?.InputKinds ?? []; - }, - async (kind, ct) => - { - GetInputDefaultSettingsResponseData? r = - await _obsClient.Inputs.GetInputDefaultSettingsAsync( - new GetInputDefaultSettingsRequestData(kind), - cancellationToken: ct - ); - return r?.DefaultInputSettings; - }, - cancellationToken - ); - - await DumpOutputSettingsAsync(cancellationToken); - await DumpStreamServiceSettingsAsync(cancellationToken); - } - - private async Task DumpKindDefaultSettingsAsync( - string panelTitle, - Func>> getKinds, - Func> getDefaults, - CancellationToken cancellationToken - ) - { - List kinds; - try - { - kinds = await getKinds(cancellationToken); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to retrieve kind list for '{Panel}'.", panelTitle); - UiError($"[red]Could not retrieve kind list for {panelTitle}.[/]"); - return; - } - - if (kinds.Count == 0) - { - _logger.LogWarning("No kinds returned for '{Panel}'.", panelTitle); - return; - } - - _logger.LogInformation( - "Found {Count} kinds for '{Panel}'. Fetching defaults...", - kinds.Count, - panelTitle - ); - - Dictionary results = new(StringComparer.OrdinalIgnoreCase); - foreach (string kind in kinds) - { - try - { - results[kind] = await getDefaults(kind, cancellationToken); - } - catch (ObsWebSocketException ex) - { - _logger.LogWarning("Could not get defaults for '{Kind}': {Msg}", kind, ex.Message); - results[kind] = null; - } - catch (Exception ex) - { - _logger.LogError(ex, "Unexpected error getting defaults for '{Kind}'.", kind); - results[kind] = null; - } - } - - RenderJsonPanel(panelTitle, SerializeKindDefaults(results)); - } - - private async Task DumpOutputSettingsAsync(CancellationToken cancellationToken) - { - List outputs; - try - { - GetOutputListResponseData? response = await _obsClient.Outputs.GetOutputListAsync( - cancellationToken: cancellationToken - ); - outputs = response?.Outputs ?? []; - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to retrieve output list."); - UiError("[red]Could not retrieve output list.[/]"); - return; - } - - if (outputs.Count == 0) - { - _logger.LogWarning("No outputs configured on this OBS instance."); - return; - } - - Dictionary results = new(StringComparer.OrdinalIgnoreCase); - foreach (OutputStub output in outputs) - { - if (output.OutputName is not { } name) - { - continue; - } - - string key = output.OutputKind is { } kind ? $"{name} ({kind})" : name; - try - { - GetOutputSettingsResponseData? r = await _obsClient.Outputs.GetOutputSettingsAsync( - new GetOutputSettingsRequestData(outputName: name), - cancellationToken: cancellationToken - ); - results[key] = r?.OutputSettings; - } - catch (ObsWebSocketException ex) - { - _logger.LogWarning( - "Could not get settings for output '{Name}': {Msg}", - name, - ex.Message - ); - results[key] = null; - } - catch (Exception ex) - { - _logger.LogError( - ex, - "Unexpected error getting settings for output '{Name}'.", - name - ); - results[key] = null; - } - } - - RenderJsonPanel("Output Settings (current instances)", SerializeKindDefaults(results)); - } - - private async Task DumpStreamServiceSettingsAsync(CancellationToken cancellationToken) - { - try - { - GetStreamServiceSettingsResponseData? response = - await _obsClient.Config.GetStreamServiceSettingsAsync( - cancellationToken: cancellationToken - ); - - ArrayBufferWriter buf = new(); - using (Utf8JsonWriter w = new(buf, new JsonWriterOptions { Indented = true })) - { - w.WriteStartObject(); - w.WriteString("streamServiceType", response?.StreamServiceType); - w.WritePropertyName("streamServiceSettings"); - if (response?.StreamServiceSettings is JsonElement el) - { - el.WriteTo(w); - } - else - { - w.WriteNullValue(); - } - - w.WriteEndObject(); - w.Flush(); - } - - RenderJsonPanel( - "Stream Service Settings", - System.Text.Encoding.UTF8.GetString(buf.WrittenSpan) - ); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to retrieve stream service settings."); - UiError("[red]Could not retrieve stream service settings.[/]"); - } - } - - private static string SerializeKindDefaults(Dictionary results) - { - ArrayBufferWriter buf = new(); - using Utf8JsonWriter w = new(buf, new JsonWriterOptions { Indented = true }); - w.WriteStartObject(); - foreach ((string kind, JsonElement? value) in results) - { - w.WritePropertyName(kind); - if (value is JsonElement el) - { - el.WriteTo(w); - } - else - { - w.WriteNullValue(); - } - } - w.WriteEndObject(); - w.Flush(); - return System.Text.Encoding.UTF8.GetString(buf.WrittenSpan); - } - - private async Task AddBrowserSourceAsync(CancellationToken cancellationToken) - { - // Step 1: Fetch scene list and determine current program scene - GetSceneListResponseData? sceneList = await _obsClient.Scenes.GetSceneListAsync( - new(), - cancellationToken: cancellationToken - ); - - if (sceneList?.Scenes is null || sceneList.Scenes.Count == 0) - { - UiWarn("Could not retrieve scene list from OBS."); - return; - } - - string currentProgramScene = sceneList.CurrentProgramSceneName ?? string.Empty; - - List sceneNames = - [ - .. sceneList - .Scenes.Select(s => s.SceneName) - .Where(n => !string.IsNullOrEmpty(n)) - .Select(n => n!), - ]; - - // Place current program scene first, then alphabetically - List orderedSceneNames = !string.IsNullOrEmpty(currentProgramScene) - ? - [ - .. sceneNames.Where(n => n == currentProgramScene), - .. sceneNames.Where(n => n != currentProgramScene).OrderBy(n => n), - ] - : [.. sceneNames.OrderBy(n => n)]; - - if (orderedSceneNames.Count == 0) - { - UiWarn("No scenes available in OBS."); - return; - } - - // Map display labels (with "(current program)" suffix) to actual names - Dictionary displayToSceneName = orderedSceneNames.ToDictionary( - n => n == currentProgramScene ? $"{n} (current program)" : n, - n => n - ); - - // Step 2: Prompt user to select a scene - string selectedSceneDisplay = AnsiConsole.Prompt( - new SelectionPrompt() - .Title("Select target [cyan]scene[/]:") - .PageSize(15) - .AddChoices(displayToSceneName.Keys) - ); - - string selectedScene = displayToSceneName[selectedSceneDisplay]; - - // Step 3: Fetch scene items and all global browser_source inputs in parallel - Task sceneItemsTask = - _obsClient.SceneItems.GetSceneItemListAsync( - new GetSceneItemListRequestData(sceneName: selectedScene), - cancellationToken: cancellationToken - ); - Task browserInputsTask = _obsClient.Inputs.GetInputListAsync( - new GetInputListRequestData("browser_source"), - cancellationToken: cancellationToken - ); - - await Task.WhenAll(sceneItemsTask, browserInputsTask).ConfigureAwait(false); - - GetSceneItemListResponseData? sceneItemList = await sceneItemsTask; - GetInputListResponseData? browserInputList = await browserInputsTask; - - // Find browser sources that already exist in the selected scene - HashSet sceneSourceNames = - sceneItemList - ?.SceneItems?.Select(si => si.SourceName ?? string.Empty) - .Where(n => !string.IsNullOrEmpty(n)) - .ToHashSet(StringComparer.OrdinalIgnoreCase) - ?? []; - - List existingBrowserSourcesInScene = - browserInputList - ?.Inputs?.Where(i => sceneSourceNames.Contains(i.InputName ?? string.Empty)) - .Select(i => i.InputName!) - .Where(n => !string.IsNullOrEmpty(n)) - .OrderBy(n => n) - .ToList() - ?? []; - - // Step 4: Prompt to create a new source or update an existing browser source - const string CreateNewChoice = "+ Create new browser source"; - List sourceChoices = [CreateNewChoice, .. existingBrowserSourcesInScene]; - - string selectedSourceChoice = AnsiConsole.Prompt( - new SelectionPrompt() - .Title($"Browser source in scene [cyan]{Markup.Escape(selectedScene)}[/]:") - .PageSize(15) - .AddChoices(sourceChoices) - ); - - bool isNewSource = selectedSourceChoice == CreateNewChoice; - string sourceName = isNewSource - ? AnsiConsole.Prompt( - new TextPrompt("New browser source [cyan]name[/]:").Validate(s => - !string.IsNullOrWhiteSpace(s) - ? ValidationResult.Success() - : ValidationResult.Error("[red]Name cannot be empty.[/]") - ) - ) - : selectedSourceChoice; - - // Step 5: Get canvas dimensions from video settings - GetVideoSettingsResponseData? videoSettings = await _obsClient.Config.GetVideoSettingsAsync( - cancellationToken: cancellationToken - ); - - if (videoSettings is null) - { - UiWarn("Could not retrieve video settings from OBS."); - return; - } - - int canvasWidth = (int)videoSettings.BaseWidth; - int canvasHeight = (int)videoSettings.BaseHeight; - UiInfo($"Canvas resolution: {canvasWidth}x{canvasHeight}"); - - // Step 6: Prompt for the overlay URL - string url = AnsiConsole.Prompt( - new TextPrompt("Browser source [cyan]URL[/]:").Validate(s => - !string.IsNullOrWhiteSpace(s) - ? ValidationResult.Success() - : ValidationResult.Error("[red]URL cannot be empty.[/]") - ) - ); - - // Step 7: Build the browser source settings payload - const string OverlayCss = - "body { background-color: rgba(0, 0, 0, 0); margin: 0px auto; overflow: hidden; }"; - - BrowserSourceSettings browserSettings = new( - Url: url, - Width: canvasWidth, - Height: canvasHeight, - FpsCustom: false, - Fps: 30, - Css: OverlayCss, - RerouteAudio: true, - WebpageControlLevel: 5, - RestartWhenActive: true - ); - - // The two paths below reach the same scene item by different routes: creating one answers - // with its id, finding an existing one costs the lookup only OBS can answer. From here on - // the rest of the method does not care which, because both produce the same handle. - SceneItemOperations item; - - // Step 8: Create new input or update existing source settings - if (isNewSource) - { - UiInfo($"Creating browser source '{sourceName}' in scene '{selectedScene}'..."); - - // Protocol level: the typed-settings CreateInput is a hand-written group helper, so it - // has no handle form. Its response carries the new scene item's id, which is what the - // handle below is built from. - CreateInputResponseData? createResult = await _obsClient.Inputs.CreateInputAsync( - inputKind: "browser_source", - inputName: sourceName, - settings: browserSettings, - sceneName: selectedScene, - sceneItemEnabled: true, - cancellationToken: cancellationToken - ); - - if (createResult is null) - { - UiWarn($"No response received when creating browser source '{sourceName}'."); - return; - } - - item = _obsClient.Scene(selectedScene).Item(createResult.SceneItemId); - UiSuccess($"Created '{sourceName}' (scene item ID: {createResult.SceneItemId})."); - } - else - { - UiInfo($"Updating browser source '{sourceName}' settings..."); - - // overlay: false resets to defaults, then applies all new settings cleanly - await _obsClient.Inputs.SetInputSettingsAsync( - inputName: sourceName, - settings: browserSettings, - overlay: false, - cancellationToken: cancellationToken - ); - - item = await _obsClient - .Scene(selectedScene) - .ItemAsync(sourceName, cancellationToken: cancellationToken); - UiSuccess($"Updated '{sourceName}' (scene item ID: {item.Handle.SceneItemId})."); - } - - // Step 9: Set Blend Mode to Normal (explicit, even though it is the default) - await item.SetBlendModeAsync("OBS_BLEND_NORMAL", cancellationToken); - - // The obs-websocket v5 protocol does not expose SetSceneItemPrivateSettings, - // so Blending Method (SRGB Off) cannot be set programmatically via this API. - AnsiConsole.MarkupLine( - "[yellow]Action required:[/] Set [bold]Blending Method[/] to [bold]sRGB Off[/] manually in OBS." - ); - AnsiConsole.MarkupLine( - "[grey] Right-click the scene item → Blending → Method → sRGB Off[/]" - ); - - RenderKeyValueTable( - $"Browser Source: {(isNewSource ? "Created" : "Updated")}", - [ - ("Name", sourceName), - ("Scene", selectedScene), - ("URL", url), - ("Dimensions", $"{canvasWidth} x {canvasHeight} (matches canvas)"), - ("FPS", "30 (fps_custom: false, uses OBS default)"), - ("CSS", OverlayCss), - ("Audio", "OBS handled (reroute_audio: true)"), - ("OBS Control Level", "Full (webpage_control_level: 5)"), - ("Refresh on Scene Active", "Yes (restart_when_active: true)"), - ("Blend Mode", "Normal (OBS_BLEND_NORMAL)"), - ("Blending Method", "sRGB Off, set manually in OBS (not exposed by WebSocket v5)"), - ] - ); - } - - private static void RenderCommandHelp() - { - Table commandTable = new() { Title = new TableTitle("Available Commands") }; - _ = commandTable.AddColumn("Command"); - _ = commandTable.AddColumn("Description"); - _ = commandTable.AddRow(Markup.Escape("help"), Markup.Escape("Show this help")); - _ = commandTable.AddRow(Markup.Escape("exit"), Markup.Escape("Exit the application")); - _ = commandTable.AddRow(Markup.Escape("status"), Markup.Escape("Show connection status")); - _ = commandTable.AddRow( - Markup.Escape("version"), - Markup.Escape("Get OBS and WebSocket version info") - ); - _ = commandTable.AddRow(Markup.Escape("scene"), Markup.Escape("Get current program scene")); - _ = commandTable.AddRow( - Markup.Escape("resolve [scene] [source]"), - Markup.Escape("Resolve a name to a uuid handle, and show what a miss reports") - ); - _ = commandTable.AddRow( - Markup.Escape("mute [input name]"), - Markup.Escape("Toggle mute for audio input, through an input handle") - ); - _ = commandTable.AddRow( - Markup.Escape("unmute [input name]"), - Markup.Escape("Alias for mute") - ); - _ = commandTable.AddRow( - Markup.Escape("get-input-settings [scene] [input]"), - Markup.Escape("Get settings for an input") - ); - _ = commandTable.AddRow( - Markup.Escape("set-text [scene] [input] [text...]"), - Markup.Escape("Set text on text source") - ); - _ = commandTable.AddRow( - Markup.Escape("list-filters [source]"), - Markup.Escape("List filters for source, through a source handle") - ); - _ = commandTable.AddRow( - Markup.Escape("toggle-filter [source] [filter]"), - Markup.Escape("Toggle filter enabled state, through one filter handle") - ); - _ = commandTable.AddRow( - Markup.Escape("media [input] [action]"), - Markup.Escape("Media transport via the typed MediaInputAction enum") - ); - _ = commandTable.AddRow( - Markup.Escape("watch [seconds]"), - Markup.Escape( - "Stream scene changes with await foreach, acting on the event's free handle (default 15s)" - ) - ); - _ = commandTable.AddRow( - Markup.Escape("batch-example"), - Markup.Escape("Run sample batch request sequence via the typed builder") - ); - _ = commandTable.AddRow( - Markup.Escape("run-transport-tests"), - Markup.Escape( - "Validate JSON and MsgPack against this OBS (version, scenes, inputs, filters, custom event, batch, settings modes 1/2/3). Prints a verdict and exits non-zero on failure." - ) - ); - _ = commandTable.AddRow( - Markup.Escape("list-subs"), - Markup.Escape("Show intended event subscription flags") - ); - _ = commandTable.AddRow( - Markup.Escape("set-subs "), - Markup.Escape("Reidentify with new event flags") - ); - _ = commandTable.AddRow( - Markup.Escape("get-all-settings-types"), - Markup.Escape( - "Dump default settings for all filter kinds, input kinds, and current stream service" - ) - ); - _ = commandTable.AddRow( - Markup.Escape("add-browser-source"), - Markup.Escape("Create or update a fullscreen browser source overlay in a scene") - ); - AnsiConsole.Write(commandTable); - } - - /// - /// Calls every read-only request in the protocol and reports the ones whose response could not - /// be deserialized. - /// - /// - /// The targeted checks elsewhere cover behaviour; this covers surface. A response record that - /// does not match what OBS sends is invisible until something reads it, and three of them were - /// shipping. Requests OBS declines for the state of the machine (no replay buffer, no group, - /// an input of the wrong kind) are reported as untested rather than as failures, so the count - /// says how much of the surface was actually exercised. - /// - /// - /// Waits for OBS to apply a profile change, which it does after answering the request. - /// - private static async Task WaitForProfileAsync( - ObsWebSocketClient client, - Func applied, - CancellationToken cancellationToken - ) - { - for (int attempt = 0; attempt < 50; attempt++) - { - GetProfileListResponseData profiles = await client - .Config.GetProfileListAsync(cancellationToken) - .ConfigureAwait(false); - if (applied(profiles)) - { - return true; - } - - await Task.Delay(100, cancellationToken).ConfigureAwait(false); - } - - return false; - } - - private const string FixtureFilterName = "__obsws_rsweep_filter"; - - /// - /// Settings for the fixtures' media source, pointing at the clip shipped next to the binary. - /// - /// - /// A media source with no file is never playing, and OBS answers the cursor requests with - /// 604, so they are sent but never exercised. - /// - private static JsonElement MediaFixtureSettings() - { - ArrayBufferWriter buffer = new(); - using (Utf8JsonWriter writer = new(buffer)) - { - writer.WriteStartObject(); - writer.WriteString( - "local_file", - Path.Combine(AppContext.BaseDirectory, "fixtures", "media-fixture.mp4") - ); - writer.WriteBoolean("is_local_file", true); - writer.WriteBoolean("looping", true); - writer.WriteEndObject(); - writer.Flush(); - } - - using JsonDocument document = JsonDocument.Parse(buffer.WrittenMemory); - return document.RootElement.Clone(); - } - - private static async Task< - List<(string Label, bool Pass, string Detail)> - > SweepEveryReadRequestAsync( - ObsWebSocketClient client, - ObsSourceKinds kinds, - GetOutputListResponseData outputs, - CancellationToken cancellationToken - ) - { - List unreadable = []; - List untested = []; - HashSet read = new(StringComparer.Ordinal); - - async Task Probe(string name, Func call) - { - try - { - await call().ConfigureAwait(false); - _ = read.Add(name); - } - catch (ObsWebSocketSerializationException ex) - { - unreadable.Add($"{name}: {ex.InnerException?.Message ?? ex.Message}"); - } - catch (ObsWebSocketRequestException ex) - { - // OBS declined for the state of the machine, so the response shape was never - // exercised. Not a defect, but not coverage either. - untested.Add($"{name} ({ex.StatusCode})"); - } - } - - // Discover targets, so the sweep needs no fixture of its own. - GetSceneListResponseData scenes = await client - .Scenes.GetSceneListAsync(new(), cancellationToken) - .ConfigureAwait(false); - string sceneName = scenes.CurrentProgramSceneName ?? scenes.Scenes[0].SceneName; - - GetInputListResponseData inputs = await client - .Inputs.GetInputListAsync(new(), cancellationToken) - .ConfigureAwait(false); - string inputName = inputs.Inputs[0].InputName; - - GetInputKindListResponseData inputKinds = await client - .Inputs.GetInputKindListAsync(new(), cancellationToken) - .ConfigureAwait(false); - string inputKind = inputKinds.InputKinds[0]; - - GetSourceFilterKindListResponseData filterKinds = await client - .Filters.GetSourceFilterKindListAsync(cancellationToken) - .ConfigureAwait(false); - string filterKind = filterKinds.SourceFilterKinds[0]; - - string outputName = outputs.Outputs[0].OutputName; - - GetGroupListResponseData groups = await client - .Scenes.GetGroupListAsync(cancellationToken) - .ConfigureAwait(false); - string? groupName = groups.Groups.Count > 0 ? groups.Groups[0] : null; - - // The discovery calls above are read requests too, and naming them here rather than - // counting them keeps the total honest when the discovery changes. - read.UnionWith([ - "GetSceneList", - "GetInputList", - "GetSceneItemList", - "GetInputKindList", - "GetSourceFilterKindList", - "GetOutputList", - "GetGroupList", - "GetStudioModeEnabled", - ]); - - // Four of these requests need OBS to be in a particular state, not just to be asked - // nicely. Without the fixture they answer InvalidResourceState and the response shape is - // never exercised, which is coverage the report would otherwise claim. - string fixtureSuffix = Guid.NewGuid().ToString("N")[..8]; - string readScene = $"__obsws_rsweep_{fixtureSuffix}"; - string audioInput = $"__obsws_rsweep_audio_{fixtureSuffix}"; - string mediaInput = $"__obsws_rsweep_media_{fixtureSuffix}"; - - GetStudioModeEnabledResponseData studioBefore = await client - .Ui.GetStudioModeEnabledAsync(cancellationToken) - .ConfigureAwait(false); - - await client - .Scenes.CreateSceneAsync(new(sceneName: readScene), cancellationToken) - .ConfigureAwait(false); - await client - .Inputs.CreateInputAsync( - new(inputName: audioInput, inputKind: kinds.AudioCapture!, sceneName: readScene), - cancellationToken - ) - .ConfigureAwait(false); - await client - .Inputs.CreateInputAsync( - new( - inputName: mediaInput, - inputKind: "ffmpeg_source", - inputSettings: MediaFixtureSettings(), - sceneName: readScene - ), - cancellationToken - ) - .ConfigureAwait(false); - await client - .Filters.CreateSourceFilterAsync( - new( - filterName: FixtureFilterName, - filterKind: "color_filter_v2", - sourceName: mediaInput - ), - cancellationToken - ) - .ConfigureAwait(false); - if (!studioBefore.StudioModeEnabled) - { - await client - .Ui.SetStudioModeEnabledAsync(new(true), cancellationToken) - .ConfigureAwait(false); - } - - // From the fixture, not from the program scene: a fresh OBS profile opens on an empty - // scene, and the seven scene item requests then go unexercised. - GetSceneItemListResponseData fixtureItems = await client - .SceneItems.GetSceneItemListAsync(new(sceneName: readScene), cancellationToken) - .ConfigureAwait(false); - long sceneItemId = fixtureItems.SceneItems[0].SceneItemId; - string itemSourceName = fixtureItems.SceneItems[0].SourceName; - - try - { - await Probe( - "GetCanvasList", - () => client.Canvases.GetCanvasListAsync(cancellationToken) - ) - .ConfigureAwait(false); - - await Probe( - "GetPersistentData", - () => - client.Config.GetPersistentDataAsync( - new( - realm: "OBS_WEBSOCKET_DATA_REALM_PROFILE", - slotName: "__obsws_sweep" - ), - cancellationToken - ) - ) - .ConfigureAwait(false); - await Probe( - "GetSceneCollectionList", - () => client.Config.GetSceneCollectionListAsync(cancellationToken) - ) - .ConfigureAwait(false); - await Probe( - "GetProfileList", - () => client.Config.GetProfileListAsync(cancellationToken) - ) - .ConfigureAwait(false); - await Probe( - "GetProfileParameter", - () => - client.Config.GetProfileParameterAsync( - new(parameterCategory: "General", parameterName: "Name"), - cancellationToken - ) - ) - .ConfigureAwait(false); - await Probe( - "GetVideoSettings", - () => client.Config.GetVideoSettingsAsync(cancellationToken) - ) - .ConfigureAwait(false); - await Probe( - "GetStreamServiceSettings", - () => client.Config.GetStreamServiceSettingsAsync(cancellationToken) - ) - .ConfigureAwait(false); - await Probe( - "GetRecordDirectory", - () => client.Config.GetRecordDirectoryAsync(cancellationToken) - ) - .ConfigureAwait(false); - - await Probe( - "GetSourceFilterDefaultSettings", - () => - client.Filters.GetSourceFilterDefaultSettingsAsync( - new(filterKind: filterKind), - cancellationToken - ) - ) - .ConfigureAwait(false); - await Probe( - "GetSourceFilterList", - () => - client.Filters.GetSourceFilterListAsync( - new(sourceName: mediaInput), - cancellationToken - ) - ) - .ConfigureAwait(false); - await Probe( - "GetSourceFilter", - () => - client.Filters.GetSourceFilterAsync( - new(filterName: FixtureFilterName, sourceName: mediaInput), - cancellationToken - ) - ) - .ConfigureAwait(false); - - await Probe("GetVersion", () => client.General.GetVersionAsync(cancellationToken)) - .ConfigureAwait(false); - await Probe("GetStats", () => client.General.GetStatsAsync(cancellationToken)) - .ConfigureAwait(false); - await Probe("GetHotkeyList", () => client.General.GetHotkeyListAsync(cancellationToken)) - .ConfigureAwait(false); - - await Probe( - "GetSpecialInputs", - () => client.Inputs.GetSpecialInputsAsync(cancellationToken) - ) - .ConfigureAwait(false); - await Probe( - "GetInputDefaultSettings", - () => - client.Inputs.GetInputDefaultSettingsAsync( - new(inputKind: inputKind), - cancellationToken - ) - ) - .ConfigureAwait(false); - await Probe( - "GetInputSettings", - () => - client.Inputs.GetInputSettingsAsync( - new(inputName: inputName), - cancellationToken - ) - ) - .ConfigureAwait(false); - await Probe( - "GetInputMute", - () => - client.Inputs.GetInputMuteAsync( - new(inputName: audioInput), - cancellationToken - ) - ) - .ConfigureAwait(false); - await Probe( - "GetInputVolume", - () => - client.Inputs.GetInputVolumeAsync( - new(inputName: audioInput), - cancellationToken - ) - ) - .ConfigureAwait(false); - await Probe( - "GetInputAudioBalance", - () => - client.Inputs.GetInputAudioBalanceAsync( - new(inputName: audioInput), - cancellationToken - ) - ) - .ConfigureAwait(false); - await Probe( - "GetInputAudioSyncOffset", - () => - client.Inputs.GetInputAudioSyncOffsetAsync( - new(inputName: audioInput), - cancellationToken - ) - ) - .ConfigureAwait(false); - await Probe( - "GetInputAudioMonitorType", - () => - client.Inputs.GetInputAudioMonitorTypeAsync( - new(inputName: audioInput), - cancellationToken - ) - ) - .ConfigureAwait(false); - await Probe( - "GetInputAudioTracks", - () => - client.Inputs.GetInputAudioTracksAsync( - new(inputName: audioInput), - cancellationToken - ) - ) - .ConfigureAwait(false); - await Probe( - "GetInputDeinterlaceMode", - () => - client.Inputs.GetInputDeinterlaceModeAsync( - new(inputName: mediaInput), - cancellationToken - ) - ) - .ConfigureAwait(false); - await Probe( - "GetInputDeinterlaceFieldOrder", - () => - client.Inputs.GetInputDeinterlaceFieldOrderAsync( - new(inputName: mediaInput), - cancellationToken - ) - ) - .ConfigureAwait(false); - await Probe( - "GetInputPropertiesListPropertyItems", - () => - client.Inputs.GetInputPropertiesListPropertyItemsAsync( - new(propertyName: "device_id", inputName: audioInput), - cancellationToken - ) - ) - .ConfigureAwait(false); - - await Probe( - "GetMediaInputStatus", - () => - client.MediaInputs.GetMediaInputStatusAsync( - new(inputName: mediaInput), - cancellationToken - ) - ) - .ConfigureAwait(false); - - await Probe( - "GetVirtualCamStatus", - () => client.Outputs.GetVirtualCamStatusAsync(cancellationToken) - ) - .ConfigureAwait(false); - await Probe( - "GetReplayBufferStatus", - () => client.Outputs.GetReplayBufferStatusAsync(cancellationToken) - ) - .ConfigureAwait(false); - // GetLastReplayBufferReplay is left untested rather than prepared for. Starting and - // stopping the replay buffer to save a clip crashed OBS: the dump lands in - // GetOutputList, on obs_encoder_get_width against the encoder the buffer had just - // freed. Enumerating outputs while one is being torn down is not something a client - // can make safe, and this sweep is not worth an OBS restart per run. - await Probe( - "GetLastReplayBufferReplay", - () => client.Outputs.GetLastReplayBufferReplayAsync(cancellationToken) - ) - .ConfigureAwait(false); - - await Probe( - "GetOutputStatus", - () => - client.Outputs.GetOutputStatusAsync( - new(outputName: outputName), - cancellationToken - ) - ) - .ConfigureAwait(false); - await Probe( - "GetOutputSettings", - () => - client.Outputs.GetOutputSettingsAsync( - new(outputName: outputName), - cancellationToken - ) - ) - .ConfigureAwait(false); - - await Probe( - "GetRecordStatus", - () => client.Record.GetRecordStatusAsync(cancellationToken) - ) - .ConfigureAwait(false); - - if (groupName is not null) - { - await Probe( - "GetGroupSceneItemList", - () => - client.SceneItems.GetGroupSceneItemListAsync( - new(sceneName: groupName), - cancellationToken - ) - ) - .ConfigureAwait(false); - } - else - { - // There is no CreateGroup request in the protocol, so a group can only come from a - // scene collection that already has one. - untested.Add("GetGroupSceneItemList (no group; the protocol cannot create one)"); - } - - await Probe( - "GetSceneItemId", - () => - client.SceneItems.GetSceneItemIdAsync( - new(sourceName: itemSourceName, sceneName: readScene), - cancellationToken - ) - ) - .ConfigureAwait(false); - await Probe( - "GetSceneItemSource", - () => - client.SceneItems.GetSceneItemSourceAsync( - new(sceneItemId: sceneItemId, sceneName: readScene), - cancellationToken - ) - ) - .ConfigureAwait(false); - await Probe( - "GetSceneItemTransform", - () => - client.SceneItems.GetSceneItemTransformAsync( - new(sceneItemId: sceneItemId, sceneName: readScene), - cancellationToken - ) - ) - .ConfigureAwait(false); - await Probe( - "GetSceneItemEnabled", - () => - client.SceneItems.GetSceneItemEnabledAsync( - new(sceneItemId: sceneItemId, sceneName: readScene), - cancellationToken - ) - ) - .ConfigureAwait(false); - await Probe( - "GetSceneItemLocked", - () => - client.SceneItems.GetSceneItemLockedAsync( - new(sceneItemId: sceneItemId, sceneName: readScene), - cancellationToken - ) - ) - .ConfigureAwait(false); - await Probe( - "GetSceneItemIndex", - () => - client.SceneItems.GetSceneItemIndexAsync( - new(sceneItemId: sceneItemId, sceneName: readScene), - cancellationToken - ) - ) - .ConfigureAwait(false); - await Probe( - "GetSceneItemBlendMode", - () => - client.SceneItems.GetSceneItemBlendModeAsync( - new(sceneItemId: sceneItemId, sceneName: readScene), - cancellationToken - ) - ) - .ConfigureAwait(false); - - await Probe( - "GetCurrentProgramScene", - () => client.Scenes.GetCurrentProgramSceneAsync(cancellationToken) - ) - .ConfigureAwait(false); - await Probe( - "GetCurrentPreviewScene", - () => client.Scenes.GetCurrentPreviewSceneAsync(cancellationToken) - ) - .ConfigureAwait(false); - await Probe( - "GetSceneSceneTransitionOverride", - () => - client.Scenes.GetSceneSceneTransitionOverrideAsync( - new(sceneName: sceneName), - cancellationToken - ) - ) - .ConfigureAwait(false); - - await Probe( - "GetSourceActive", - () => - client.Sources.GetSourceActiveAsync( - new(sourceName: inputName), - cancellationToken - ) - ) - .ConfigureAwait(false); - await Probe( - "GetSourceScreenshot", - () => - client.Sources.GetSourceScreenshotAsync( - new(imageFormat: "png", sourceName: sceneName), - cancellationToken - ) - ) - .ConfigureAwait(false); - - await Probe( - "GetStreamStatus", - () => client.Stream.GetStreamStatusAsync(cancellationToken) - ) - .ConfigureAwait(false); + // Step 4: Prompt to create a new source or update an existing browser source + const string CreateNewChoice = "+ Create new browser source"; + List sourceChoices = [CreateNewChoice, .. existingBrowserSourcesInScene]; - await Probe( - "GetTransitionKindList", - () => client.Transitions.GetTransitionKindListAsync(cancellationToken) - ) - .ConfigureAwait(false); - await Probe( - "GetSceneTransitionList", - () => client.Transitions.GetSceneTransitionListAsync(cancellationToken) - ) - .ConfigureAwait(false); - await Probe( - "GetCurrentSceneTransition", - () => client.Transitions.GetCurrentSceneTransitionAsync(cancellationToken) - ) - .ConfigureAwait(false); - await Probe( - "GetCurrentSceneTransitionCursor", - () => client.Transitions.GetCurrentSceneTransitionCursorAsync(cancellationToken) - ) - .ConfigureAwait(false); + string selectedSourceChoice = AnsiConsole.Prompt( + new SelectionPrompt() + .Title($"Browser source in scene [cyan]{Markup.Escape(selectedScene)}[/]:") + .PageSize(15) + .AddChoices(sourceChoices) + ); - await Probe( - "GetStudioModeEnabled", - () => client.Ui.GetStudioModeEnabledAsync(cancellationToken) + bool isNewSource = selectedSourceChoice == CreateNewChoice; + string sourceName = isNewSource + ? AnsiConsole.Prompt( + new TextPrompt("New browser source [cyan]name[/]:").Validate(s => + !string.IsNullOrWhiteSpace(s) + ? ValidationResult.Success() + : ValidationResult.Error("[red]Name cannot be empty.[/]") ) - .ConfigureAwait(false); - await Probe("GetMonitorList", () => client.Ui.GetMonitorListAsync(cancellationToken)) - .ConfigureAwait(false); - } - finally - { - if (!studioBefore.StudioModeEnabled) - { - await client - .Ui.SetStudioModeEnabledAsync(new(false), CancellationToken.None) - .ConfigureAwait(false); - } - - foreach (string fixture in new[] { audioInput, mediaInput }) - { - await client - .Inputs.RemoveInputAsync(new(inputName: fixture), CancellationToken.None) - .ConfigureAwait(false); - } - - await client - .Scenes.RemoveSceneAsync(new(sceneName: readScene), CancellationToken.None) - .ConfigureAwait(false); - } - - return - [ - ( - "Every read request deserializes", - unreadable.Count == 0 && read.Count + untested.Count >= 60, - unreadable.Count > 0 ? string.Join(" | ", unreadable.Take(3)) - : read.Count + untested.Count < 60 - ? $"only {read.Count + untested.Count} of 60 accounted for; a probe is missing" - : untested.Count == 0 ? $"all {read.Count} of 60 read" - : $"{read.Count} of 60 read; untested: {string.Join(", ", untested)}" - ), - ]; - } + ) + : selectedSourceChoice; - /// - /// Sends every write request that can be exercised without taking the machine somewhere it - /// cannot come back from, and reports the ones that could not be serialized. - /// - /// - /// Most write requests answer with no payload, so what this covers is the request side. That - /// is not a formality: SetInputAudioTracks could not be sent over MessagePack at all, - /// for the same missing formatter that made GetInputAudioTracks unreadable. - /// - /// Everything runs against a scene, an input and a filter this method creates and removes. - /// Requests that can only touch global state read the current value and write it back, so the - /// call is real and the setting is unchanged. - /// - /// - /// Deliberately not sent, because the cost of running them is not a shape bug: anything that - /// starts a stream, a recording, the replay buffer or the virtual camera; profile and scene - /// collection switching, which reloads OBS underneath the run; the dialog and projector - /// requests, which open windows; TriggerHotkeyByName and - /// PressInputPropertiesButton, which do whatever the target happens to do; - /// CallVendorRequest, which needs a plugin; and Sleep, which is batch only. - /// - /// - private static async Task< - List<(string Label, bool Pass, string Detail)> - > SweepEveryWriteRequestAsync( - ObsWebSocketClient client, - ObsSourceKinds kinds, - GetOutputListResponseData outputs, - CancellationToken cancellationToken - ) - { - List unsendable = []; - List declined = []; - int sent = 0; + // Step 5: Get canvas dimensions from video settings + GetVideoSettingsResponseData? videoSettings = await _obsClient.Config.GetVideoSettingsAsync( + cancellationToken: cancellationToken + ); - async Task Probe(string name, Func call) + if (videoSettings is null) { - try - { - await call().ConfigureAwait(false); - sent++; - } - catch (ObsWebSocketSerializationException ex) - { - unsendable.Add($"{name}: {ex.InnerException?.Message ?? ex.Message}"); - } - catch (ObsWebSocketRequestException ex) - { - declined.Add($"{name} ({ex.StatusCode})"); - } + UiWarn("Could not retrieve video settings from OBS."); + return; } - string suffix = Guid.NewGuid().ToString("N")[..8]; - string sceneName = $"__obsws_wsweep_{suffix}"; - string renamedScene = $"{sceneName}_r"; - string inputName = $"__obsws_wsweep_in_{suffix}"; - string renamedInput = $"{inputName}_r"; - string filterName = "__obsws_wsweep_filter"; - string renamedFilter = $"{filterName}_r"; - - GetSceneListResponseData scenesBefore = await client - .Scenes.GetSceneListAsync(new(), cancellationToken) - .ConfigureAwait(false); - string originalProgramScene = scenesBefore.CurrentProgramSceneName!; - - string outputName = outputs.Outputs[0].OutputName; - - // ── Fixture ────────────────────────────────────────────────────────── - await Probe( - "CreateScene", - () => client.Scenes.CreateSceneAsync(new(sceneName: sceneName), cancellationToken) - ) - .ConfigureAwait(false); - await Probe( - "CreateInput", - () => - client.Inputs.CreateInputAsync( - new( - inputName: inputName, - inputKind: "color_source_v3", - sceneName: sceneName - ), - cancellationToken - ) - ) - .ConfigureAwait(false); - - // A colour source has no audio and cannot be deinterlaced. Pointing every audio and media - // request at one is how six read requests came back declined rather than exercised. - string audioInput = $"__obsws_wsweep_audio_{suffix}"; - string mediaInput = $"__obsws_wsweep_media_{suffix}"; - await Probe( - "CreateInput (audio)", - () => - client.Inputs.CreateInputAsync( - new( - inputName: audioInput, - inputKind: kinds.AudioCapture!, - sceneName: sceneName - ), - cancellationToken - ) - ) - .ConfigureAwait(false); - await Probe( - "CreateInput (media)", - () => - client.Inputs.CreateInputAsync( - new( - inputName: mediaInput, - inputKind: "ffmpeg_source", - inputSettings: MediaFixtureSettings(), - sceneName: sceneName - ), - cancellationToken - ) - ) - .ConfigureAwait(false); - - try - { - GetSceneItemListResponseData fixtureItems = await client - .SceneItems.GetSceneItemListAsync(new(sceneName: sceneName), cancellationToken) - .ConfigureAwait(false); - long itemId = fixtureItems.SceneItems[0].SceneItemId; - - // ── Scenes ─────────────────────────────────────────────────────── - await Probe( - "SetCurrentProgramScene", - () => - client.Scenes.SetCurrentProgramSceneAsync( - new(sceneName: sceneName), - cancellationToken - ) - ) - .ConfigureAwait(false); - await Probe( - "SetSceneSceneTransitionOverride", - () => - client.Scenes.SetSceneSceneTransitionOverrideAsync( - new(sceneName: sceneName, transitionName: "Fade"), - cancellationToken - ) - ) - .ConfigureAwait(false); - await Probe( - "SetSceneName", - () => - client.Scenes.SetSceneNameAsync( - new(newSceneName: renamedScene, sceneName: sceneName), - cancellationToken - ) - ) - .ConfigureAwait(false); - sceneName = renamedScene; - - // ── Scene items ────────────────────────────────────────────────── - await Probe( - "SetSceneItemEnabled", - () => - client.SceneItems.SetSceneItemEnabledAsync( - new(sceneItemId: itemId, sceneItemEnabled: true, sceneName: sceneName), - cancellationToken - ) - ) - .ConfigureAwait(false); - await Probe( - "SetSceneItemLocked", - () => - client.SceneItems.SetSceneItemLockedAsync( - new(sceneItemId: itemId, sceneItemLocked: false, sceneName: sceneName), - cancellationToken - ) - ) - .ConfigureAwait(false); - await Probe( - "SetSceneItemIndex", - () => - client.SceneItems.SetSceneItemIndexAsync( - new(sceneItemId: itemId, sceneItemIndex: 0, sceneName: sceneName), - cancellationToken - ) - ) - .ConfigureAwait(false); - await Probe( - "SetSceneItemBlendMode", - () => - client.SceneItems.SetSceneItemBlendModeAsync( - new( - sceneItemId: itemId, - sceneItemBlendMode: "OBS_BLEND_NORMAL", - sceneName: sceneName - ), - cancellationToken - ) - ) - .ConfigureAwait(false); - - // A whole transform read back from OBS is refused: it carries the source dimensions - // OBS computes and will not accept back. A caller sets the fields they mean to. - SceneItemTransformPatchStub transformPatch = new() - { - PositionX = 0.0, - PositionY = 0.0, - Rotation = 0.0, - }; - await Probe( - "SetSceneItemTransform", - () => - client.SceneItems.SetSceneItemTransformAsync( - new( - sceneItemId: itemId, - sceneItemTransform: transformPatch, - sceneName: sceneName - ), - cancellationToken - ) - ) - .ConfigureAwait(false); - - long? addedItemId = null; - try - { - CreateSceneItemResponseData added = await client - .SceneItems.CreateSceneItemAsync( - new(sceneName: sceneName, sourceName: inputName), - cancellationToken - ) - .ConfigureAwait(false); - addedItemId = added.SceneItemId; - sent++; - } - catch (ObsWebSocketRequestException ex) - { - declined.Add($"CreateSceneItem ({ex.StatusCode})"); - } - - long? duplicatedItemId = null; - try - { - DuplicateSceneItemResponseData duplicated = await client - .SceneItems.DuplicateSceneItemAsync( - new(sceneItemId: itemId, sceneName: sceneName), - cancellationToken - ) - .ConfigureAwait(false); - duplicatedItemId = duplicated.SceneItemId; - sent++; - } - catch (ObsWebSocketRequestException ex) - { - declined.Add($"DuplicateSceneItem ({ex.StatusCode})"); - } - - // Only the two this sweep added. Removing the last scene item that references an input - // destroys the input, which took the audio and media fixtures with it. - foreach (long extra in new[] { addedItemId, duplicatedItemId }.OfType()) - { - await Probe( - "RemoveSceneItem", - () => - client.SceneItems.RemoveSceneItemAsync( - new(sceneItemId: extra, sceneName: sceneName), - cancellationToken - ) - ) - .ConfigureAwait(false); - } - - // ── Inputs ─────────────────────────────────────────────────────── - await Probe( - "SetInputSettings", - () => - client.Inputs.SetInputSettingsAsync( - inputName, - new ColorSourceSettings { Width = 320, Height = 180 }, - cancellationToken: cancellationToken - ) - ) - .ConfigureAwait(false); - await Probe( - "SetInputMute", - () => - client.Inputs.SetInputMuteAsync( - new(inputMuted: false, inputName: audioInput), - cancellationToken - ) - ) - .ConfigureAwait(false); - await Probe( - "ToggleInputMute", - () => - client.Inputs.ToggleInputMuteAsync( - new(inputName: audioInput), - cancellationToken - ) - ) - .ConfigureAwait(false); - await Probe( - "SetInputVolume", - () => - client.Inputs.SetInputVolumeAsync( - new(inputName: audioInput, inputVolumeMul: 1.0), - cancellationToken - ) - ) - .ConfigureAwait(false); - await Probe( - "SetInputAudioBalance", - () => - client.Inputs.SetInputAudioBalanceAsync( - new(inputAudioBalance: 0.5, inputName: audioInput), - cancellationToken - ) - ) - .ConfigureAwait(false); - await Probe( - "SetInputAudioSyncOffset", - () => - client.Inputs.SetInputAudioSyncOffsetAsync( - new(inputAudioSyncOffset: 0, inputName: audioInput), - cancellationToken - ) - ) - .ConfigureAwait(false); - await Probe( - "SetInputAudioMonitorType", - () => - client.Inputs.SetInputAudioMonitorTypeAsync( - new(monitorType: "OBS_MONITORING_TYPE_NONE", inputName: audioInput), - cancellationToken - ) - ) - .ConfigureAwait(false); - - // The request that could not be sent over MessagePack at all. - GetInputAudioTracksResponseData? tracks = null; - try - { - tracks = await client - .Inputs.GetInputAudioTracksAsync(new(inputName: audioInput), cancellationToken) - .ConfigureAwait(false); - } - catch (ObsWebSocketRequestException) - { - // Deliberately not logged: the write below is probed either way and reports what - // OBS said about it. - } - await Probe( - "SetInputAudioTracks", - () => - client.Inputs.SetInputAudioTracksAsync( - new( - inputAudioTracks: tracks?.InputAudioTracks - ?? new Dictionary { ["1"] = true }, - inputName: audioInput - ), - cancellationToken - ) - ) - .ConfigureAwait(false); - - await Probe( - "SetInputDeinterlaceMode", - () => - client.Inputs.SetInputDeinterlaceModeAsync( - new( - inputDeinterlaceMode: "OBS_DEINTERLACE_MODE_DISABLE", - inputName: mediaInput - ), - cancellationToken - ) - ) - .ConfigureAwait(false); - await Probe( - "SetInputDeinterlaceFieldOrder", - () => - client.Inputs.SetInputDeinterlaceFieldOrderAsync( - new( - inputDeinterlaceFieldOrder: "OBS_DEINTERLACE_FIELD_ORDER_TOP", - inputName: mediaInput - ), - cancellationToken - ) - ) - .ConfigureAwait(false); - await Probe( - "SetInputName", - () => - client.Inputs.SetInputNameAsync( - new(newInputName: renamedInput, inputName: inputName), - cancellationToken - ) - ) - .ConfigureAwait(false); - inputName = renamedInput; - - // ── Media inputs ───────────────────────────────────────────────── - // The cursor requests only reach their own answer while the clip is playing. - await Probe( - "TriggerMediaInputAction (play)", - () => - client.MediaInputs.TriggerMediaInputActionAsync( - new(mediaAction: MediaInputAction.Play, inputName: mediaInput), - cancellationToken - ) - ) - .ConfigureAwait(false); - - // Playback starts a moment after OBS accepts the action, and the cursor requests are - // answered only once it has. - for (int attempt = 0; attempt < 20; attempt++) - { - GetMediaInputStatusResponseData status = await client - .MediaInputs.GetMediaInputStatusAsync( - new(inputName: mediaInput), - cancellationToken - ) - .ConfigureAwait(false); - if (status.MediaState is "OBS_MEDIA_STATE_PLAYING" or "OBS_MEDIA_STATE_PAUSED") - { - break; - } - - await Task.Delay(100, cancellationToken).ConfigureAwait(false); - } - - await Probe( - "SetMediaInputCursor", - () => - client.MediaInputs.SetMediaInputCursorAsync( - new(mediaCursor: 0, inputName: mediaInput), - cancellationToken - ) - ) - .ConfigureAwait(false); - await Probe( - "OffsetMediaInputCursor", - () => - client.MediaInputs.OffsetMediaInputCursorAsync( - new(mediaCursorOffset: 0, inputName: mediaInput), - cancellationToken - ) - ) - .ConfigureAwait(false); - await Probe( - "TriggerMediaInputAction", - () => - client.MediaInputs.TriggerMediaInputActionAsync( - new(mediaAction: MediaInputAction.Stop, inputName: mediaInput), - cancellationToken - ) - ) - .ConfigureAwait(false); - await Probe( - "PressInputPropertiesButton", - () => - client.Inputs.PressInputPropertiesButtonAsync( - new(propertyName: "__obsws_no_such_button", inputName: inputName), - cancellationToken - ) - ) - .ConfigureAwait(false); - - // ── Filters ────────────────────────────────────────────────────── - await Probe( - "CreateSourceFilter", - () => - client.Filters.CreateSourceFilterAsync( - new( - filterName: filterName, - filterKind: "color_filter_v2", - sourceName: inputName - ), - cancellationToken - ) - ) - .ConfigureAwait(false); - await Probe( - "SetSourceFilterEnabled", - () => - client.Filters.SetSourceFilterEnabledAsync( - new(filterName: filterName, filterEnabled: true, sourceName: inputName), - cancellationToken - ) - ) - .ConfigureAwait(false); - await Probe( - "SetSourceFilterIndex", - () => - client.Filters.SetSourceFilterIndexAsync( - new(filterName: filterName, filterIndex: 0, sourceName: inputName), - cancellationToken - ) - ) - .ConfigureAwait(false); - await Probe( - "SetSourceFilterSettings", - () => - client.Filters.SetSourceFilterSettingsAsync( - inputName, - filterName, - new ColorCorrectionFilterSettings { Opacity = 1.0 }, - cancellationToken: cancellationToken - ) - ) - .ConfigureAwait(false); - await Probe( - "SetSourceFilterName", - () => - client.Filters.SetSourceFilterNameAsync( - new( - filterName: filterName, - newFilterName: renamedFilter, - sourceName: inputName - ), - cancellationToken - ) - ) - .ConfigureAwait(false); - await Probe( - "RemoveSourceFilter", - () => - client.Filters.RemoveSourceFilterAsync( - new(filterName: renamedFilter, sourceName: inputName), - cancellationToken - ) - ) - .ConfigureAwait(false); - - // ── General ────────────────────────────────────────────────────── - using JsonDocument custom = JsonDocument.Parse("""{"obswsSweep":true}"""); - await Probe( - "BroadcastCustomEvent", - () => - client.General.BroadcastCustomEventAsync( - new(eventData: custom.RootElement.Clone()), - cancellationToken - ) - ) - .ConfigureAwait(false); - await Probe( - "TriggerHotkeyByKeySequence", - () => - client.General.TriggerHotkeyByKeySequenceAsync( - new( - keyId: "OBS_KEY_F13", - keyModifiers: new TriggerHotkeyByKeySequenceRequestData_KeyModifiers( - shift: false, - control: false, - alt: false, - command: false - ) - ), - cancellationToken - ) - ) - .ConfigureAwait(false); - - // ── Config, every one a read then a write of the same value ────── - await Probe( - "SetPersistentData", - () => - client.Config.SetPersistentDataAsync( - new( - realm: "OBS_WEBSOCKET_DATA_REALM_PROFILE", - slotName: "__obsws_sweep", - slotValue: JsonDocument.Parse("\"probe\"").RootElement.Clone() - ), - cancellationToken - ) - ) - .ConfigureAwait(false); - - // CreateProfile activates the profile it creates, and removing the active one leaves - // OBS writing into a directory it has deleted (#42). Switch back first, and wait for - // each step: OBS answers these before it applies them. - string sweepProfile = $"__obsws_wsweep_profile_{suffix}"; - GetProfileListResponseData profilesBefore = await client - .Config.GetProfileListAsync(cancellationToken) - .ConfigureAwait(false); - string originalProfile = profilesBefore.CurrentProfileName!; - - await Probe( - "CreateProfile", - () => - client.Config.CreateProfileAsync( - new(profileName: sweepProfile), - cancellationToken - ) - ) - .ConfigureAwait(false); + int canvasWidth = (int)videoSettings.BaseWidth; + int canvasHeight = (int)videoSettings.BaseHeight; + UiInfo($"Canvas resolution: {canvasWidth}x{canvasHeight}"); - if ( - await WaitForProfileAsync( - client, - list => list.CurrentProfileName == sweepProfile, - cancellationToken - ) - .ConfigureAwait(false) - ) - { - await Probe( - "SetCurrentProfile", - () => - client.Config.SetCurrentProfileAsync( - new(profileName: originalProfile), - cancellationToken - ) - ) - .ConfigureAwait(false); + // Step 6: Prompt for the overlay URL + string url = AnsiConsole.Prompt( + new TextPrompt("Browser source [cyan]URL[/]:").Validate(s => + !string.IsNullOrWhiteSpace(s) + ? ValidationResult.Success() + : ValidationResult.Error("[red]URL cannot be empty.[/]") + ) + ); - if ( - await WaitForProfileAsync( - client, - list => list.CurrentProfileName == originalProfile, - cancellationToken - ) - .ConfigureAwait(false) - ) - { - await Probe( - "RemoveProfile", - () => - client.Config.RemoveProfileAsync( - new(profileName: sweepProfile), - cancellationToken - ) - ) - .ConfigureAwait(false); - _ = await WaitForProfileAsync( - client, - list => !list.Profiles.Contains(sweepProfile), - cancellationToken - ) - .ConfigureAwait(false); - } - else - { - declined.Add("RemoveProfile (not sent: OBS stayed on the sweep's profile)"); - } - } - else - { - declined.Add( - "SetCurrentProfile, RemoveProfile (not sent: the new profile never became active)" - ); - } + // Step 7: Build the browser source settings payload + const string OverlayCss = + "body { background-color: rgba(0, 0, 0, 0); margin: 0px auto; overflow: hidden; }"; - GetProfileParameterResponseData profileParameter = await client - .Config.GetProfileParameterAsync( - new(parameterCategory: "Output", parameterName: "Mode"), - cancellationToken - ) - .ConfigureAwait(false); - await Probe( - "SetProfileParameter", - () => - client.Config.SetProfileParameterAsync( - new( - parameterCategory: "Output", - parameterName: "Mode", - parameterValue: profileParameter.ParameterValue - ?? profileParameter.DefaultParameterValue - ?? "Simple" - ), - cancellationToken - ) - ) - .ConfigureAwait(false); - - GetVideoSettingsResponseData video = await client - .Config.GetVideoSettingsAsync(cancellationToken) - .ConfigureAwait(false); - await Probe( - "SetVideoSettings", - () => - client.Config.SetVideoSettingsAsync( - new( - fpsNumerator: video.FpsNumerator, - fpsDenominator: video.FpsDenominator, - baseWidth: video.BaseWidth, - baseHeight: video.BaseHeight, - outputWidth: video.OutputWidth, - outputHeight: video.OutputHeight - ), - cancellationToken - ) - ) - .ConfigureAwait(false); - - GetRecordDirectoryResponseData recordDirectory = await client - .Config.GetRecordDirectoryAsync(cancellationToken) - .ConfigureAwait(false); - await Probe( - "SetRecordDirectory", - () => - client.Config.SetRecordDirectoryAsync( - new(recordDirectory: recordDirectory.RecordDirectory), - cancellationToken - ) - ) - .ConfigureAwait(false); - - GetStreamServiceSettingsResponseData streamService = await client - .Config.GetStreamServiceSettingsAsync(cancellationToken) - .ConfigureAwait(false); - // Written back unchanged where OBS has settings. A fresh install has none and OBS - // rejects an empty object, so the request would never be exercised; a placeholder for - // the service it already reports keeps the check real and overwrites nothing. - JsonElement serviceSettings = - streamService.StreamServiceSettings is JsonElement existing - && existing.ValueKind == JsonValueKind.Object - && existing.EnumerateObject().Any() - ? existing - : JsonDocument - .Parse("""{"server":"auto","service":"Twitch"}""") - .RootElement.Clone(); - await Probe( - "SetStreamServiceSettings", - () => - client.Config.SetStreamServiceSettingsAsync( - new( - streamServiceType: streamService.StreamServiceType, - streamServiceSettings: serviceSettings - ), - cancellationToken - ) - ) - .ConfigureAwait(false); - - // ── Transitions, same read then write ──────────────────────────── - GetCurrentSceneTransitionResponseData transition = await client - .Transitions.GetCurrentSceneTransitionAsync(cancellationToken) - .ConfigureAwait(false); - await Probe( - "SetCurrentSceneTransition", - () => - client.Transitions.SetCurrentSceneTransitionAsync( - new(transitionName: transition.TransitionName), - cancellationToken - ) - ) - .ConfigureAwait(false); - await Probe( - "SetCurrentSceneTransitionDuration", - () => - client.Transitions.SetCurrentSceneTransitionDurationAsync( - new(transitionDuration: transition.TransitionDuration ?? 300), - cancellationToken - ) - ) - .ConfigureAwait(false); - // Fade and Cut have nothing to configure and OBS answers 606. Select a configurable - // transition in OBS to exercise this one; the CI scene collection ships with one. - if (transition.TransitionConfigurable) - { - await Probe( - "SetCurrentSceneTransitionSettings", - () => - client.Transitions.SetCurrentSceneTransitionSettingsAsync( - new( - transitionSettings: transition.TransitionSettings - ?? JsonDocument.Parse("{}").RootElement.Clone() - ), - cancellationToken - ) - ) - .ConfigureAwait(false); - } - else - { - declined.Add( - $"SetCurrentSceneTransitionSettings (not sent: '{transition.TransitionName}' has nothing to configure)" - ); - } + BrowserSourceSettings browserSettings = new( + Url: url, + Width: canvasWidth, + Height: canvasHeight, + FpsCustom: false, + Fps: 30, + Css: OverlayCss, + RerouteAudio: true, + WebpageControlLevel: 5, + RestartWhenActive: true + ); - // ── UI and studio mode, restored below ─────────────────────────── - GetStudioModeEnabledResponseData studio = await client - .Ui.GetStudioModeEnabledAsync(cancellationToken) - .ConfigureAwait(false); - await Probe( - "SetStudioModeEnabled", - () => client.Ui.SetStudioModeEnabledAsync(new(true), cancellationToken) - ) - .ConfigureAwait(false); - await Probe( - "SetCurrentPreviewScene", - () => - client.Scenes.SetCurrentPreviewSceneAsync( - new(sceneName: sceneName), - cancellationToken - ) - ) - .ConfigureAwait(false); - await Probe( - "SetTBarPosition", - () => - client.Transitions.SetTBarPositionAsync( - new(position: 0.0, release: true), - cancellationToken - ) - ) - .ConfigureAwait(false); - await Probe( - "TriggerStudioModeTransition", - () => client.Transitions.TriggerStudioModeTransitionAsync(cancellationToken) - ) - .ConfigureAwait(false); - if (!studio.StudioModeEnabled) - { - await client - .Ui.SetStudioModeEnabledAsync(new(false), CancellationToken.None) - .ConfigureAwait(false); - } + // The two paths below reach the same scene item by different routes: creating one answers + // with its id, finding an existing one costs the lookup only OBS can answer. From here on + // the rest of the method does not care which, because both produce the same handle. + SceneItemOperations item; - // ── Requests OBS should decline in this state, sent anyway so the - // request side is still exercised ─────────────────────────────── - await Probe("StopRecord", () => client.Record.StopRecordAsync(cancellationToken)) - .ConfigureAwait(false); - await Probe( - "ToggleRecordPause", - () => client.Record.ToggleRecordPauseAsync(cancellationToken) - ) - .ConfigureAwait(false); - await Probe("PauseRecord", () => client.Record.PauseRecordAsync(cancellationToken)) - .ConfigureAwait(false); - await Probe("ResumeRecord", () => client.Record.ResumeRecordAsync(cancellationToken)) - .ConfigureAwait(false); - await Probe( - "SplitRecordFile", - () => client.Record.SplitRecordFileAsync(cancellationToken) - ) - .ConfigureAwait(false); - await Probe( - "CreateRecordChapter", - () => - client.Record.CreateRecordChapterAsync( - new(chapterName: "__obsws_sweep"), - cancellationToken - ) - ) - .ConfigureAwait(false); - await Probe("StopStream", () => client.Stream.StopStreamAsync(cancellationToken)) - .ConfigureAwait(false); - await Probe( - "SendStreamCaption", - () => - client.Stream.SendStreamCaptionAsync( - new(captionText: "__obsws_sweep"), - cancellationToken - ) - ) - .ConfigureAwait(false); - await Probe( - "StopReplayBuffer", - () => client.Outputs.StopReplayBufferAsync(cancellationToken) - ) - .ConfigureAwait(false); - await Probe( - "SaveReplayBuffer", - () => client.Outputs.SaveReplayBufferAsync(cancellationToken) - ) - .ConfigureAwait(false); + // Step 8: Create new input or update existing source settings + if (isNewSource) + { + UiInfo($"Creating browser source '{sourceName}' in scene '{selectedScene}'..."); - await Probe( - "StopOutput", - () => - client.Outputs.StopOutputAsync( - new(outputName: outputName), - cancellationToken - ) - ) - .ConfigureAwait(false); - - // SetOutputSettings is deliberately not sent. Writing settings back to a real - // output wedged the output subsystem: GetOutputList then timed out for the rest of - // the session, in checks that had nothing to do with the sweep. - declined.Add("SetOutputSettings (not sent: wedges the output subsystem)"); - - // ── Sources ────────────────────────────────────────────────────── - string screenshotPath = Path.Combine(Path.GetTempPath(), $"obsws_sweep_{suffix}.png"); - await Probe( - "SaveSourceScreenshot", - () => - client.Sources.SaveSourceScreenshotAsync( - new( - imageFormat: "png", - imageFilePath: screenshotPath, - sourceName: sceneName - ), - cancellationToken - ) - ) - .ConfigureAwait(false); - if (File.Exists(screenshotPath)) + // Protocol level: the typed-settings CreateInput is a hand-written group helper, so it + // has no handle form. Its response carries the new scene item's id, which is what the + // handle below is built from. + CreateInputResponseData? createResult = await _obsClient.Inputs.CreateInputAsync( + inputKind: "browser_source", + inputName: sourceName, + settings: browserSettings, + sceneName: selectedScene, + sceneItemEnabled: true, + cancellationToken: cancellationToken + ); + + if (createResult is null) { - File.Delete(screenshotPath); + UiWarn($"No response received when creating browser source '{sourceName}'."); + return; } + + item = _obsClient.Scene(selectedScene).Item(createResult.SceneItemId); + UiSuccess($"Created '{sourceName}' (scene item ID: {createResult.SceneItemId})."); } - finally + else { - await client - .Scenes.SetCurrentProgramSceneAsync( - new(sceneName: originalProgramScene), - CancellationToken.None - ) - .ConfigureAwait(false); + UiInfo($"Updating browser source '{sourceName}' settings..."); - foreach (string fixture in new[] { inputName, audioInput, mediaInput }) - { - await Probe( - "RemoveInput", - () => - client.Inputs.RemoveInputAsync( - new(inputName: fixture), - CancellationToken.None - ) - ) - .ConfigureAwait(false); - } - await Probe( - "RemoveScene", - () => - client.Scenes.RemoveSceneAsync( - new(sceneName: sceneName), - CancellationToken.None - ) - ) - .ConfigureAwait(false); + // overlay: false resets to defaults, then applies all new settings cleanly + await _obsClient.Inputs.SetInputSettingsAsync( + inputName: sourceName, + settings: browserSettings, + overlay: false, + cancellationToken: cancellationToken + ); + + item = await _obsClient + .Scene(selectedScene) + .ItemAsync(sourceName, cancellationToken: cancellationToken); + UiSuccess($"Updated '{sourceName}' (scene item ID: {item.Handle.SceneItemId})."); } - return - [ - ( - "Every write request serializes", - unsendable.Count == 0, - unsendable.Count == 0 - // A decline still proves the request serialized and reached OBS, which is - // what this sweep covers; only unsendable is a defect. - ? $"{sent + declined.Count} serialized ({sent} accepted, {declined.Count} " - + $"declined for machine state): {string.Join(", ", declined)}" - : string.Join(" | ", unsendable.Take(3)) - ), - ]; + // Step 9: Set Blend Mode to Normal (explicit, even though it is the default) + await item.SetBlendModeAsync("OBS_BLEND_NORMAL", cancellationToken); + + // The obs-websocket v5 protocol does not expose SetSceneItemPrivateSettings, + // so Blending Method (SRGB Off) cannot be set programmatically via this API. + AnsiConsole.MarkupLine( + "[yellow]Action required:[/] Set [bold]Blending Method[/] to [bold]sRGB Off[/] manually in OBS." + ); + AnsiConsole.MarkupLine( + "[grey] Right-click the scene item → Blending → Method → sRGB Off[/]" + ); + + RenderKeyValueTable( + $"Browser Source: {(isNewSource ? "Created" : "Updated")}", + [ + ("Name", sourceName), + ("Scene", selectedScene), + ("URL", url), + ("Dimensions", $"{canvasWidth} x {canvasHeight} (matches canvas)"), + ("FPS", "30 (fps_custom: false, uses OBS default)"), + ("CSS", OverlayCss), + ("Audio", "OBS handled (reroute_audio: true)"), + ("OBS Control Level", "Full (webpage_control_level: 5)"), + ("Refresh on Scene Active", "Yes (restart_when_active: true)"), + ("Blend Mode", "Normal (OBS_BLEND_NORMAL)"), + ("Blending Method", "sRGB Off, set manually in OBS (not exposed by WebSocket v5)"), + ] + ); } - /// - /// Runs an action expected to throw, and hands back the exception it threw. - /// - private static async Task ExpectThrowAsync(Func action) - where TException : Exception + private static void RenderCommandHelp() { - try - { - await action().ConfigureAwait(false); - } - catch (TException expected) - { - return expected; - } - - throw new InvalidOperationException( - $"Expected {typeof(TException).Name}, but the call succeeded." + Table commandTable = new() { Title = new TableTitle("Available Commands") }; + _ = commandTable.AddColumn("Command"); + _ = commandTable.AddColumn("Description"); + _ = commandTable.AddRow(Markup.Escape("help"), Markup.Escape("Show this help")); + _ = commandTable.AddRow(Markup.Escape("exit"), Markup.Escape("Exit the application")); + _ = commandTable.AddRow(Markup.Escape("status"), Markup.Escape("Show connection status")); + _ = commandTable.AddRow( + Markup.Escape("version"), + Markup.Escape("Get OBS and WebSocket version info") + ); + _ = commandTable.AddRow(Markup.Escape("scene"), Markup.Escape("Get current program scene")); + _ = commandTable.AddRow( + Markup.Escape("resolve [scene] [source]"), + Markup.Escape("Resolve a name to a uuid handle, and show what a miss reports") + ); + _ = commandTable.AddRow( + Markup.Escape("mute [input name]"), + Markup.Escape("Toggle mute for audio input, through an input handle") + ); + _ = commandTable.AddRow( + Markup.Escape("unmute [input name]"), + Markup.Escape("Alias for mute") + ); + _ = commandTable.AddRow( + Markup.Escape("get-input-settings [scene] [input]"), + Markup.Escape("Get settings for an input") + ); + _ = commandTable.AddRow( + Markup.Escape("set-text [scene] [input] [text...]"), + Markup.Escape("Set text on text source") + ); + _ = commandTable.AddRow( + Markup.Escape("list-filters [source]"), + Markup.Escape("List filters for source, through a source handle") + ); + _ = commandTable.AddRow( + Markup.Escape("toggle-filter [source] [filter]"), + Markup.Escape("Toggle filter enabled state, through one filter handle") + ); + _ = commandTable.AddRow( + Markup.Escape("media [input] [action]"), + Markup.Escape("Media transport via the typed MediaInputAction enum") ); + _ = commandTable.AddRow( + Markup.Escape("watch [seconds]"), + Markup.Escape( + "Stream scene changes with await foreach, acting on the event's free handle (default 15s)" + ) + ); + _ = commandTable.AddRow( + Markup.Escape("batch-example"), + Markup.Escape("Run sample batch request sequence via the typed builder") + ); + _ = commandTable.AddRow( + Markup.Escape("list-subs"), + Markup.Escape("Show intended event subscription flags") + ); + _ = commandTable.AddRow( + Markup.Escape("set-subs "), + Markup.Escape("Reidentify with new event flags") + ); + _ = commandTable.AddRow( + Markup.Escape("get-all-settings-types"), + Markup.Escape( + "Dump default settings for all filter kinds, input kinds, and current stream service" + ) + ); + _ = commandTable.AddRow( + Markup.Escape("add-browser-source"), + Markup.Escape("Create or update a fullscreen browser source overlay in a scene") + ); + AnsiConsole.Write(commandTable); } private static void RenderKeyValueTable( @@ -6412,19 +1804,3 @@ internal sealed record WorkerGainDbSettings([property: JsonPropertyName("db")] d DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault )] internal sealed partial class WorkerSettingsJsonContext : JsonSerializerContext { } - -/// -/// A request payload this library has no metadata for, sent with the consumer's own context. -/// -/// The scene to list items for. -internal sealed record ConsumerSceneRequest( - [property: System.Text.Json.Serialization.JsonPropertyName("sceneName")] string SceneName -); - -/// -/// The consumer side serializer context, the AOT safe way to describe a payload the library does -/// not model. -/// -[System.Text.Json.Serialization.JsonSerializable(typeof(ConsumerSceneRequest))] -internal sealed partial class ExampleRequestContext - : System.Text.Json.Serialization.JsonSerializerContext; diff --git a/ObsWebSocket.Tests/BatchBuilderAndEnumTests.cs b/ObsWebSocket.Tests/BatchBuilderAndEnumTests.cs index f5ead61..7cb37f9 100644 --- a/ObsWebSocket.Tests/BatchBuilderAndEnumTests.cs +++ b/ObsWebSocket.Tests/BatchBuilderAndEnumTests.cs @@ -15,7 +15,7 @@ namespace ObsWebSocket.Tests; public sealed class ObsBatchBuilderTests { [TestMethod] - public void Builder_PairsRequestTypeWithItsOwnPayload() + public void Builder_TypedAdd_PairsRequestTypeWithPayload() { ObsBatchBuilder builder = new(); _ = builder.General.GetVersion(); @@ -39,7 +39,7 @@ public void Builder_PairsRequestTypeWithItsOwnPayload() } [TestMethod] - public void Builder_ReturnsReferencesInSubmissionOrder() + public void Builder_SeveralAdds_ReturnsReferencesInOrder() { ObsBatchBuilder builder = new(); BatchRef version = builder.General.GetVersion(); @@ -75,7 +75,7 @@ public void Builder_RepeatedRequestType_YieldsDistinctReferences() } [TestMethod] - public void Add_AcceptsRawRequestTypesAndPayloads() + public void Add_RawRequest_IsAccepted() { using JsonDocument doc = JsonDocument.Parse("""{"inputName":"Mic"}"""); @@ -91,7 +91,7 @@ public void Add_AcceptsRawRequestTypesAndPayloads() } [TestMethod] - public void Build_ReturnsIndependentCopies() + public void Build_CalledTwice_ReturnsIndependentCopies() { ObsBatchBuilder builder = new(); _ = builder.General.GetVersion(); @@ -118,7 +118,7 @@ public void TypedMethod_WithNullPayload_Throws() => public sealed class ProtocolEnumTests { [TestMethod] - public void OutputState_RoundTripsThroughWireValues() + public void OutputState_WireValue_RoundTrips() { foreach (OutputState value in Enum.GetValues()) { @@ -132,7 +132,7 @@ public void OutputState_RoundTripsThroughWireValues() } [TestMethod] - public void MediaInputAction_RoundTripsThroughWireValues() + public void MediaInputAction_WireValue_RoundTrips() { foreach (MediaInputAction value in Enum.GetValues()) { @@ -146,7 +146,7 @@ public void MediaInputAction_RoundTripsThroughWireValues() } [TestMethod] - public void ToWireValue_MatchesTheProtocolConstants() + public void ToWireValue_EveryMember_MatchesProtocolConstant() { Assert.AreEqual( ObsOutputState.OBS_WEBSOCKET_OUTPUT_STARTED, @@ -167,7 +167,7 @@ public void FromWireValue_WithUnknownOrNullInput_ReturnsNull() } [TestMethod] - public void ProtocolConstants_UsableAsConstantPatterns() + public void ProtocolConstants_InSwitch_UsableAsPatterns() { // These are const rather than static readonly, which is what lets them appear as // constant patterns in a switch. This would not compile against static readonly. diff --git a/ObsWebSocket.Tests/BatchResultTests.cs b/ObsWebSocket.Tests/BatchResultTests.cs index 64e902d..b241cf9 100644 --- a/ObsWebSocket.Tests/BatchResultTests.cs +++ b/ObsWebSocket.Tests/BatchResultTests.cs @@ -106,7 +106,7 @@ public void TryGetData_FailedRequest_ReturnsFalseWithoutThrowing() } [TestMethod] - public void AllSucceededAndGetFailures_ReflectStatuses() + public void AllSucceededAndGetFailures_MixedStatuses_ReflectThem() { List> results = [ diff --git a/ObsWebSocket.Tests/ClientContractTests.cs b/ObsWebSocket.Tests/ClientContractTests.cs index 54faf1e..5aaace8 100644 --- a/ObsWebSocket.Tests/ClientContractTests.cs +++ b/ObsWebSocket.Tests/ClientContractTests.cs @@ -371,7 +371,7 @@ public async Task AddNamedClient_WithDifferentFormats_EachGetsItsOwnSerializer() /// [TestMethod] [Timeout(TimeoutMs)] - public async Task ChangingFormat_ChangesTheSerializerTheNextConnectionUses() + public async Task ConnectAsync_FormatChanged_NextConnectionUsesNewSerializer() { List requested = []; @@ -692,7 +692,7 @@ public void Dispose() #endregion [TestMethod] - public async Task ACallRefusedAsNotReadyIsRetriedWhenEnabled() + public async Task CallAsync_NotReadyWithRetryOn_Retries() { int sends = 0; ( @@ -755,7 +755,7 @@ CancellationToken _ } [TestMethod] - public async Task ACallRefusedAsNotReadyThrowsWhenRetryIsOff() + public async Task CallAsync_NotReadyWithRetryOff_Throws() { int sends = 0; ( @@ -809,7 +809,7 @@ await Assert.ThrowsExactlyAsync(() => } [TestMethod] - public async Task AnotherFailureCodeIsNotRetried() + public async Task CallAsync_OtherFailureCode_NotRetried() { int sends = 0; ( @@ -872,7 +872,7 @@ private static string ReadRequestId(ReadOnlyMemory buffer) } [TestMethod] - public async Task AValueCallRefusedAsNotReadyIsRetried() + public async Task CallAsyncValue_NotReady_Retries() { int sends = 0; ( @@ -910,7 +910,7 @@ Mock mockConnection } [TestMethod] - public async Task ABatchRefusedAsNotReadyIsRetried() + public async Task CallBatchAsync_NotReady_Retries() { int sends = 0; ( @@ -994,7 +994,7 @@ CancellationToken _ } [TestMethod] - public void ARegisteredPipelineIsResolvable() + public void NotReadyPipeline_Registered_IsResolvable() { ServiceCollection services = new(); _ = services.AddLogging(); @@ -1012,7 +1012,7 @@ public void ARegisteredPipelineIsResolvable() } [TestMethod] - public async Task ARegisteredReconnectDelaySourceIsUsed() + public async Task ReconnectDelays_Registered_AreUsed() { StubReconnectDelays delays = new(); ServiceCollection services = new(); diff --git a/ObsWebSocket.Tests/ClientMessageHandlingTests.cs b/ObsWebSocket.Tests/ClientMessageHandlingTests.cs new file mode 100644 index 0000000..b18bf63 --- /dev/null +++ b/ObsWebSocket.Tests/ClientMessageHandlingTests.cs @@ -0,0 +1,365 @@ +using ObsWebSocket.Core; +using ObsWebSocket.Core.Protocol.Generated; +using ObsWebSocket.Core.Protocol.Responses; +using ObsWebSocket.Tests.Fakes; + +namespace ObsWebSocket.Tests; + +/// +/// What the client does with frames it did not ask for or cannot read. A newer OBS, a stray +/// response or a truncated frame must not take the connection down, and a lost connection must +/// come back on its own when that is configured. +/// +[TestClass] +public sealed class ClientMessageHandlingTests +{ + private const int TestTimeout = 30_000; + + private const string Version = + """{"obsVersion":"32.2.2","obsWebSocketVersion":"5.7.0","rpcVersion":1,"availableRequests":[],"supportedImageFormats":[],"platform":"windows","platformDescription":"Windows 11"}"""; + + private static FakeObsServer ServerWithVersion() + { + FakeObsServer server = new(); + _ = server.Returns("GetVersion", Version); + return server; + } + + /// Proves the connection still works after whatever the test pushed. + private static async Task AssertStillServingAsync(FakeObsClient fake) + { + Assert.IsTrue(fake.Client.IsConnected); + GetVersionResponseData version = await fake.Client.General.GetVersionAsync(); + Assert.AreEqual("32.2.2", version.ObsVersion); + } + + [TestMethod] + [Timeout(TestTimeout)] + [DataRow("not json at all")] + [DataRow("""{"op":99,"d":{}}""")] + [DataRow("""{"op":5,"d":null}""")] + [DataRow("""{"op":5,"d":{"eventIntent":0}}""")] + [DataRow( + """{"op":5,"d":{"eventType":"SomethingOnlyANewerObsSends","eventIntent":0,"eventData":{}}}""" + )] + [DataRow( + """{"op":5,"d":{"eventType":"CurrentProgramSceneChanged","eventIntent":0,"eventData":{"sceneName":42}}}""" + )] + [DataRow("""{"op":5,"d":{"eventType":"CurrentProgramSceneChanged","eventIntent":0}}""")] + [DataRow( + """{"op":7,"d":{"requestType":"GetVersion","requestId":"nobody-asked","requestStatus":{"result":true,"code":100}}}""" + )] + [DataRow("""{"op":7,"d":null}""")] + [DataRow("""{"op":9,"d":{"requestId":"nobody-asked","results":[]}}""")] + [DataRow("""{"op":9,"d":null}""")] + [DataRow("""{"op":2,"d":{"negotiatedRpcVersion":1}}""")] + [DataRow("""{"op":0,"d":{"obsWebSocketVersion":"5.7.0","rpcVersion":1}}""")] + public async Task ReceiveLoop_UnusableFrame_DropsItAndKeepsServing(string frame) + { + await using FakeObsClient fake = await FakeObsClient.ConnectAsync(ServerWithVersion()); + + fake.Server.PushRaw(frame); + + await AssertStillServingAsync(fake); + } + + [TestMethod] + [Timeout(TestTimeout)] + public async Task EventDispatch_HandlerThrows_NextEventStillDelivered() + { + await using FakeObsClient fake = await FakeObsClient.ConnectAsync(ServerWithVersion()); + + int delivered = 0; + TaskCompletionSource second = new(TaskCreationOptions.RunContinuationsAsynchronously); + fake.Client.CurrentProgramSceneChanged += (_, _) => + { + if (Interlocked.Increment(ref delivered) == 1) + { + throw new InvalidOperationException("a subscriber bug"); + } + + second.TrySetResult(); + }; + + const string payload = """{"sceneName":"Live","sceneUuid":"p"}"""; + fake.Server.RaiseEvent("CurrentProgramSceneChanged", payload); + fake.Server.RaiseEvent("CurrentProgramSceneChanged", payload); + + await second.Task.WaitAsync(TimeSpan.FromSeconds(10)); + await AssertStillServingAsync(fake); + } + + [TestMethod] + [Timeout(TestTimeout)] + public async Task CustomEvent_WithBroadcastData_RaisesItsPayload() + { + await using FakeObsClient fake = await FakeObsClient.ConnectAsync(ServerWithVersion()); + + TaskCompletionSource received = new( + TaskCreationOptions.RunContinuationsAsynchronously + ); + fake.Client.CustomEvent += (_, e) => + received.TrySetResult(e.EventData.EventData?.GetProperty("cue").GetString() ?? ""); + + fake.Server.RaiseEvent("CustomEvent", """{"cue":"lights"}"""); + + Assert.AreEqual("lights", await received.Task.WaitAsync(TimeSpan.FromSeconds(10))); + } + + [TestMethod] + [Timeout(TestTimeout)] + public async Task ReceiveLoop_MessageLargerThanBuffer_ReassemblesIt() + { + await using FakeObsClient fake = await FakeObsClient.ConnectAsync(ServerWithVersion()); + + string longName = new('x', 256 * 1024); + TaskCompletionSource received = new( + TaskCreationOptions.RunContinuationsAsynchronously + ); + fake.Client.CurrentProgramSceneChanged += (_, e) => + received.TrySetResult(e.EventData.SceneName); + + fake.Server.RaiseEvent( + "CurrentProgramSceneChanged", + $$"""{"sceneName":"{{longName}}","sceneUuid":"p"}""" + ); + + Assert.AreEqual(longName, await received.Task.WaitAsync(TimeSpan.FromSeconds(10))); + } + + [TestMethod] + [Timeout(TestTimeout)] + public async Task ReceiveLoop_MessageOverCeiling_EndsConnection() + { + await using FakeObsClient fake = await FakeObsClient.ConnectAsync( + ServerWithVersion(), + options => options.MaxIncomingMessageBytes = ObsWebSocketClient.ReceiveBufferSize + ); + + TaskCompletionSource disconnected = new( + TaskCreationOptions.RunContinuationsAsynchronously + ); + fake.Client.Disconnected += (_, e) => disconnected.TrySetResult(e.ReasonException); + + fake.Server.RaiseEvent( + "CurrentProgramSceneChanged", + $$"""{"sceneName":"{{new string('x', 64 * 1024)}}","sceneUuid":"p"}""" + ); + + Exception? reason = await disconnected.Task.WaitAsync(TimeSpan.FromSeconds(10)); + Assert.IsInstanceOfType(reason); + Assert.IsFalse(fake.Client.IsConnected); + } + + [TestMethod] + [Timeout(TestTimeout)] + public async Task CallAsync_ServerClosesWhilePending_Fails() + { + FakeObsServer server = new(); + TaskCompletionSource requestSeen = new(TaskCreationOptions.RunContinuationsAsynchronously); + _ = server.OnRequest( + "GetVersion", + _ => + { + requestSeen.TrySetResult(); + server.CloseFromServer((int)WebSocketCloseCode.SessionInvalidated); + return FakeObsServer.RequestOutcome.NoReply; + } + ); + + await using FakeObsClient fake = await FakeObsClient.ConnectAsync(server); + + Task pending = fake.Client.General.GetVersionAsync(); + await requestSeen.Task.WaitAsync(TimeSpan.FromSeconds(10)); + + _ = await Assert.ThrowsAsync(() => pending); + } + + [TestMethod] + [Timeout(TestTimeout)] + public async Task ReidentifyAsync_Acknowledged_RecordsNewSubscriptions() + { + await using FakeObsClient fake = await FakeObsClient.ConnectAsync( + ServerWithVersion(), + options => options.EventSubscriptions = EventSubscription.None + ); + + Assert.AreEqual(EventSubscription.None, fake.Client.CurrentEventSubscriptions); + + await fake.Client.ReidentifyAsync( + (uint)(EventSubscription.Scenes | EventSubscription.Inputs) + ); + + Assert.AreEqual( + EventSubscription.Scenes | EventSubscription.Inputs, + fake.Client.CurrentEventSubscriptions + ); + await AssertStillServingAsync(fake); + } + + [TestMethod] + [Timeout(TestTimeout)] + public async Task ReidentifyAsync_NeverAnswered_FailsAndKeepsOldSubscriptions() + { + FakeObsServer server = ServerWithVersion(); + await using FakeObsClient fake = await FakeObsClient.ConnectAsync( + server, + options => options.EventSubscriptions = EventSubscription.Scenes + ); + server.IgnoreReidentify = true; + + _ = await Assert.ThrowsAsync(() => + fake.Client.ReidentifyAsync((uint)EventSubscription.Inputs, timeoutMs: 200) + ); + + Assert.AreEqual(EventSubscription.Scenes, fake.Client.CurrentEventSubscriptions); + + // The failed attempt must not wedge the next one. + server.IgnoreReidentify = false; + await fake.Client.ReidentifyAsync((uint)EventSubscription.Inputs); + Assert.AreEqual(EventSubscription.Inputs, fake.Client.CurrentEventSubscriptions); + } + + [TestMethod] + [Timeout(TestTimeout)] + public async Task ReidentifyAsync_Cancelled_ThrowsOperationCanceled() + { + FakeObsServer server = ServerWithVersion(); + await using FakeObsClient fake = await FakeObsClient.ConnectAsync(server); + server.IgnoreReidentify = true; + using CancellationTokenSource cancel = new(TimeSpan.FromMilliseconds(100)); + + _ = await Assert.ThrowsAsync(() => + fake.Client.ReidentifyAsync( + (uint)EventSubscription.Inputs, + timeoutMs: 10_000, + cancellationToken: cancel.Token + ) + ); + await AssertStillServingAsync(fake); + } + + [TestMethod] + [Timeout(TestTimeout)] + public async Task EventDispatch_OversizedUnreadablePayload_DropsItAndKeepsServing() + { + await using FakeObsClient fake = await FakeObsClient.ConnectAsync(ServerWithVersion()); + + // Wrong shape and well past the length the log keeps. + fake.Server.RaiseEvent( + "CurrentProgramSceneChanged", + $$"""{"sceneName":[{{string.Join(",", Enumerable.Repeat("1", 2000))}}]}""" + ); + + await AssertStillServingAsync(fake); + } + + [TestMethod] + [Timeout(TestTimeout)] + public async Task ReidentifyAsync_Concurrent_BothComplete() + { + await using FakeObsClient fake = await FakeObsClient.ConnectAsync(ServerWithVersion()); + + await Task.WhenAll( + fake.Client.ReidentifyAsync((uint)EventSubscription.Scenes), + fake.Client.ReidentifyAsync((uint)EventSubscription.Inputs) + ); + + Assert.IsNotNull(fake.Client.CurrentEventSubscriptions); + } + + [TestMethod] + [Timeout(TestTimeout)] + public async Task ConnectionLoop_LostWithAutoReconnectOn_Reconnects() + { + FakeObsServer server = ServerWithVersion(); + await using FakeObsClient fake = await FakeObsClient.ConnectAsync( + server, + options => + { + options.AutoReconnectEnabled = true; + options.MaxReconnectAttempts = 5; + options.InitialReconnectDelayMs = 10; + options.MaxReconnectDelayMs = 20; + } + ); + + int connects = 0; + TaskCompletionSource reconnected = new(TaskCreationOptions.RunContinuationsAsynchronously); + fake.Client.Connected += (_, _) => + { + if (Interlocked.Increment(ref connects) == 1) + { + reconnected.TrySetResult(); + } + }; + + server.CloseFromServer((int)WebSocketCloseCode.SessionInvalidated); + + await reconnected.Task.WaitAsync(TimeSpan.FromSeconds(10)); + await AssertStillServingAsync(fake); + } + + [TestMethod] + [Timeout(TestTimeout)] + public async Task ConnectionLoop_LostWithAutoReconnectOff_StaysDisconnected() + { + FakeObsServer server = ServerWithVersion(); + await using FakeObsClient fake = await FakeObsClient.ConnectAsync(server); + + int attempts = 0; + fake.Client.Connecting += (_, _) => Interlocked.Increment(ref attempts); + TaskCompletionSource disconnected = new(TaskCreationOptions.RunContinuationsAsynchronously); + fake.Client.Disconnected += (_, _) => disconnected.TrySetResult(); + + server.CloseFromServer((int)WebSocketCloseCode.SessionInvalidated); + + await disconnected.Task.WaitAsync(TimeSpan.FromSeconds(10)); + Assert.AreEqual( + 0, + attempts, + "the server would have accepted a reconnect, so none may be tried" + ); + Assert.IsFalse(fake.Client.IsConnected); + } + + [TestMethod] + [Timeout(TestTimeout)] + public async Task DisconnectAsync_ThenRequest_RequestRefused() + { + await using FakeObsClient fake = await FakeObsClient.ConnectAsync(ServerWithVersion()); + + await fake.Client.DisconnectAsync(); + + Assert.IsFalse(fake.Client.IsConnected); + _ = await Assert.ThrowsExactlyAsync(() => + fake.Client.General.GetVersionAsync() + ); + } + + [TestMethod] + [Timeout(TestTimeout)] + public async Task CallAsync_NoReplyWithinTimeout_ThrowsTimeout() + { + FakeObsServer server = new(); + + _ = server.OnRequest("GetVersion", _ => FakeObsServer.RequestOutcome.NoReply); + + await using FakeObsClient fake = await FakeObsClient.ConnectAsync( + server, + options => options.RequestTimeoutMs = 200 + ); + + _ = await Assert.ThrowsExactlyAsync(() => + fake.Client.General.GetVersionAsync() + ); + await AssertStillServingAfterTimeoutAsync(fake); + } + + private static async Task AssertStillServingAfterTimeoutAsync(FakeObsClient fake) + { + // A timed out request is the caller's failure, not the connection's. + _ = fake.Server.Returns("GetVersion", Version); + await AssertStillServingAsync(fake); + } +} diff --git a/ObsWebSocket.Tests/ClientSurfaceTests.cs b/ObsWebSocket.Tests/ClientSurfaceTests.cs new file mode 100644 index 0000000..cf23a26 --- /dev/null +++ b/ObsWebSocket.Tests/ClientSurfaceTests.cs @@ -0,0 +1,340 @@ +using System.Reflection; +using System.Text.Json; +using MessagePack; +using Microsoft.Extensions.Options; +using ObsWebSocket.Core; +using ObsWebSocket.Core.Events.Generated; +using ObsWebSocket.Core.Protocol; +using ObsWebSocket.Core.Protocol.Generated; +using ObsWebSocket.Core.Protocol.Responses; +using ObsWebSocket.Core.Serialization; +using ObsWebSocket.Tests.Fakes; + +namespace ObsWebSocket.Tests; + +/// +/// The smaller public surface around the client: option validation, the event and batch +/// conveniences, reading batch results, and the exception types callers catch. +/// +[TestClass] +public sealed class ClientSurfaceTests +{ + private const int TestTimeout = 30_000; + + #region Options validation + + [TestMethod] + [DataRow(null, "ServerUri is required")] + [DataRow("http://localhost:4455", "scheme must be ws or wss")] + public void Validate_EndpointNotWebSocket_Fails(string? uri, string expected) + { + ObsWebSocketClientOptions options = new() { ServerUri = uri is null ? null : new Uri(uri) }; + + ValidateOptionsResult result = new ObsWebSocketClientOptionsValidator().Validate( + null, + options + ); + + Assert.IsTrue(result.Failed); + Assert.Contains(expected, result.FailureMessage!); + } + + [TestMethod] + [DataRow(nameof(ObsWebSocketClientOptions.HandshakeTimeoutMs), 0, "HandshakeTimeoutMs")] + [DataRow(nameof(ObsWebSocketClientOptions.RequestTimeoutMs), 0, "RequestTimeoutMs")] + [DataRow( + nameof(ObsWebSocketClientOptions.InitialReconnectDelayMs), + -1, + "InitialReconnectDelayMs" + )] + [DataRow(nameof(ObsWebSocketClientOptions.MaxReconnectDelayMs), -5, "MaxReconnectDelayMs")] + [DataRow( + nameof(ObsWebSocketClientOptions.MaxIncomingMessageBytes), + 16, + "MaxIncomingMessageBytes" + )] + public void Validate_NumberOutOfRange_Fails(string property, int value, string expected) + { + ObsWebSocketClientOptions options = new() { ServerUri = new Uri("ws://localhost:4455") }; + typeof(ObsWebSocketClientOptions).GetProperty(property)!.SetValue(options, value); + + ValidateOptionsResult result = new ObsWebSocketClientOptionsValidator().Validate( + null, + options + ); + + Assert.IsTrue(result.Failed); + Assert.Contains(expected, result.FailureMessage!); + } + + [TestMethod] + public void Validate_ShrinkingBackoff_Fails() + { + ObsWebSocketClientOptions options = new() + { + ServerUri = new Uri("wss://localhost:4455"), + ReconnectBackoffMultiplier = 0.5, + }; + + ValidateOptionsResult result = new ObsWebSocketClientOptionsValidator().Validate( + null, + options + ); + + Assert.Contains("ReconnectBackoffMultiplier", result.FailureMessage!); + } + + [TestMethod] + public void Validate_DefaultsWithEndpoint_Succeeds() + { + ValidateOptionsResult result = new ObsWebSocketClientOptionsValidator().Validate( + null, + new ObsWebSocketClientOptions { ServerUri = new Uri("ws://localhost:4455") } + ); + + Assert.IsTrue(result.Succeeded); + } + + #endregion + + #region Event and batch conveniences + + [TestMethod] + [Timeout(TestTimeout)] + public async Task WaitForEventAsync_EachOverload_ReturnsArrivingEvent() + { + FakeObsServer server = new(); + await using FakeObsClient fake = await FakeObsClient.ConnectAsync(server); + ObsWebSocketClient client = fake.Client; + const string payload = """{"sceneName":"Live","sceneUuid":"p"}"""; + + Task untimed = + client.WaitForEventAsync(); + Task timed = + client.WaitForEventAsync(TimeSpan.FromSeconds(10)); + Task filtered = + client.WaitForEventAsync(e => + e.EventData.SceneName == "Live" + ); + + server.RaiseEvent("CurrentProgramSceneChanged", payload); + + foreach ( + Task wait in new[] { untimed, timed, filtered } + ) + { + Assert.AreEqual( + "Live", + (await wait.WaitAsync(TimeSpan.FromSeconds(10))).EventData.SceneName + ); + } + } + + [TestMethod] + [Timeout(TestTimeout)] + public async Task WaitForEventAsync_UntimedAndCancelled_ThrowsOperationCanceled() + { + await using FakeObsClient fake = await FakeObsClient.ConnectAsync(new FakeObsServer()); + using CancellationTokenSource cancel = new(); + + Task wait = + fake.Client.WaitForEventAsync(cancel.Token); + await cancel.CancelAsync(); + + _ = await Assert.ThrowsAsync(() => wait); + } + + [TestMethod] + [Timeout(TestTimeout)] + public async Task CallBatchAsync_BuiltInline_ResultsAddressableByReference() + { + FakeObsServer server = new(); + _ = server.Returns("GetStudioModeEnabled", """{"studioModeEnabled":true}"""); + await using FakeObsClient fake = await FakeObsClient.ConnectAsync(server); + + BatchRef? studio = null; + BatchResults results = await fake.Client.CallBatchAsync(batch => + studio = batch.Ui.GetStudioModeEnabled() + ); + + Assert.IsTrue(results.Get(studio!.Value).StudioModeEnabled); + Assert.IsTrue(results.TryGet(studio.Value, out GetStudioModeEnabledResponseData? read)); + Assert.IsTrue(read!.StudioModeEnabled); + } + + [TestMethod] + [Timeout(TestTimeout)] + public async Task CallBatchAsync_Parallel_RefusesTypedReads() + { + FakeObsServer server = new(); + _ = server.Returns("GetStudioModeEnabled", """{"studioModeEnabled":true}"""); + await using FakeObsClient fake = await FakeObsClient.ConnectAsync(server); + + ObsBatchBuilder builder = new(); + BatchRef studio = builder.Ui.GetStudioModeEnabled(); + + BatchResults results = await fake.Client.CallBatchAsync( + builder, + executionType: RequestBatchExecutionType.Parallel + ); + + // OBS mis-pairs payloads under parallel execution, so reading one is refused. + _ = Assert.ThrowsExactly(() => results.Get(studio)); + Assert.IsFalse(results.TryGet(studio, out GetStudioModeEnabledResponseData? _)); + } + + [TestMethod] + [Timeout(TestTimeout)] + public async Task CallBatchAsync_NullBatch_Throws() + { + await using FakeObsClient fake = await FakeObsClient.ConnectAsync(new FakeObsServer()); + + _ = await Assert.ThrowsExactlyAsync(() => + fake.Client.CallBatchAsync((ObsBatchBuilder)null!) + ); + _ = await Assert.ThrowsExactlyAsync(() => + fake.Client.CallBatchAsync((Action)null!) + ); + } + + #endregion + + #region Reading batch results + + private static RequestResponsePayload Result(object? data, bool succeeded = true) => + new("GetStudioModeEnabled", "1", new RequestStatus(succeeded, succeeded ? 100 : 600), data); + + [TestMethod] + public void GetData_AlreadyTyped_ReturnsSameInstance() + { + GetStudioModeEnabledResponseData typed = new() { StudioModeEnabled = true }; + + Assert.AreSame(typed, Result(typed).GetData()); + } + + [TestMethod] + public void GetData_MessagePackBytes_Deserializes() + { + ReadOnlyMemory packed = MessagePackSerializer.ConvertFromJson( + """{"studioModeEnabled":true}""" + ); + + Assert.IsTrue( + Result(packed).GetRequiredData().StudioModeEnabled + ); + } + + [TestMethod] + public void GetData_MessagePackWrongShape_ThrowsSerialization() + { + ReadOnlyMemory packed = MessagePackSerializer.ConvertFromJson( + """{"studioModeEnabled":"not a bool"}""" + ); + + _ = Assert.ThrowsExactly(() => + Result(packed).GetData() + ); + Assert.IsFalse(Result(packed).TryGetData(out GetStudioModeEnabledResponseData? _)); + } + + [TestMethod] + public void GetData_UnreadableRuntimeType_ThrowsSerialization() + { + _ = Assert.ThrowsExactly(() => + Result(new object()).GetData() + ); + } + + [TestMethod] + public void GetData_AbsentPayload_ReturnsNull() + { + Assert.IsNull(Result(null).GetData()); + Assert.IsNull(Result(default(JsonElement)).GetData()); + Assert.IsNull( + Result(JsonDocument.Parse("null").RootElement.Clone()) + .GetData() + ); + Assert.IsFalse(Result(null).TryGetData(out GetStudioModeEnabledResponseData? _)); + } + + [TestMethod] + public void TryGetData_FailedResult_ReturnsFalse() + { + RequestResponsePayload failed = Result( + JsonDocument.Parse("""{"studioModeEnabled":true}""").RootElement.Clone(), + succeeded: false + ); + + Assert.IsFalse(failed.TryGetData(out GetStudioModeEnabledResponseData? data)); + Assert.IsNull(data); + _ = Assert.ThrowsExactly(() => + failed.GetRequiredData() + ); + } + + [TestMethod] + public void GetData_JsonWrongShape_ThrowsSerialization() + { + RequestResponsePayload wrong = Result( + JsonDocument.Parse("""{"studioModeEnabled":"yes"}""").RootElement.Clone() + ); + + _ = Assert.ThrowsExactly(() => + wrong.GetData() + ); + Assert.IsFalse(wrong.TryGetData(out GetStudioModeEnabledResponseData? _)); + } + + #endregion + + #region Exception types + + private static IEnumerable PublicExceptions() => + typeof(ObsWebSocketException) + .Assembly.GetExportedTypes() + .Where(t => typeof(Exception).IsAssignableFrom(t) && !t.IsAbstract); + + [TestMethod] + public void PublicExceptions_StandardConstructors_CarryMessageAndCause() + { + InvalidOperationException cause = new("cause"); + + foreach (Type type in PublicExceptions()) + { + ConstructorInfo? withMessage = type.GetConstructor([typeof(string)]); + ConstructorInfo? withCause = + type.GetConstructor([typeof(string), typeof(Exception)]) + ?? type.GetConstructor([typeof(string), typeof(Exception)]); + ConstructorInfo? empty = type.GetConstructor(Type.EmptyTypes); + + Assert.IsNotNull(withMessage, $"{type.Name} lacks a message constructor"); + Assert.IsNotNull(withCause, $"{type.Name} lacks a message and cause constructor"); + Assert.IsNotNull(empty, $"{type.Name} lacks a parameterless constructor"); + + Exception fromMessage = (Exception)withMessage.Invoke(["broke"]); + Assert.AreEqual("broke", fromMessage.Message, type.Name); + + Exception fromCause = (Exception)withCause.Invoke(["broke", cause]); + Assert.AreSame(cause, fromCause.InnerException, type.Name); + + _ = (Exception)empty.Invoke([]); + } + } + + [TestMethod] + public void SceneItemNotFoundException_Constructed_ExposesSceneAndSource() + { + InvalidOperationException cause = new("cause"); + + SceneItemNotFoundException plain = new("missing", "Live", "Camera"); + SceneItemNotFoundException withCause = new("missing", cause, "Live", "Camera"); + + Assert.AreEqual("Live", plain.SceneName); + Assert.AreEqual("Camera", plain.SourceName); + Assert.AreEqual("Live", withCause.SceneName); + Assert.AreEqual("Camera", withCause.SourceName); + Assert.AreSame(cause, withCause.InnerException); + } + + #endregion +} diff --git a/ObsWebSocket.Tests/ConnectionFailurePathTests.cs b/ObsWebSocket.Tests/ConnectionFailurePathTests.cs new file mode 100644 index 0000000..65543bb --- /dev/null +++ b/ObsWebSocket.Tests/ConnectionFailurePathTests.cs @@ -0,0 +1,386 @@ +using System.Net.WebSockets; +using Microsoft.Extensions.Time.Testing; +using Moq; +using ObsWebSocket.Core; +using ObsWebSocket.Core.Networking; +using ObsWebSocket.Core.Serialization; + +namespace ObsWebSocket.Tests; + +/// +/// The paths a connection takes when something goes wrong: a handshake that never completes, a +/// refused connect, a send that throws. These fail quietly in production and are reachable +/// through the mocked transport. +/// +[TestClass] +public sealed class ConnectionFailurePathTests +{ + private const int TestTimeout = 30_000; + private static readonly Uri s_uri = new("ws://testhost:4455"); + + /// Drives the client until settles. + private static async Task PumpForFailureAsync(Task pending, FakeTimeProvider time) + { + for (int i = 0; i < 4000 && !pending.IsCompleted; i++) + { + time.Advance(TimeSpan.FromMilliseconds(50)); + await Task.Delay(1); + } + + try + { + await pending; + return null; + } + catch (Exception error) + { + return error; + } + } + + /// A transport that opens, sends nothing, and blocks on receive. + private static void SetupSilentTransport(Mock connection) + { + _ = connection + .Setup(c => c.ConnectAsync(s_uri, It.IsAny())) + .Callback(() => connection.SetupGet(c => c.State).Returns(WebSocketState.Open)) + .Returns(Task.CompletedTask); + + _ = connection + .Setup(c => c.ReceiveAsync(It.IsAny>(), It.IsAny())) + .Returns( + (Memory _, CancellationToken token) => + new ValueTask( + Task.Delay(Timeout.Infinite, token) + .ContinueWith( + _ => new ValueWebSocketReceiveResult( + 0, + WebSocketMessageType.Close, + true + ), + TaskContinuationOptions.ExecuteSynchronously + ) + ) + ); + } + + [TestMethod] + [Timeout(TestTimeout)] + public async Task ConnectAsync_HandshakeNeverArrives_Fails() + { + FakeTimeProvider time = new(); + ( + ObsWebSocketClient client, + Mock connection, + Mock _, + Mock __ + ) = TestUtils.BuildMockedClientInfrastructure( + options => + { + options.ServerUri = s_uri; + options.HandshakeTimeoutMs = 200; + options.AutoReconnectEnabled = false; + options.MaxReconnectAttempts = 0; + }, + timeProvider: time + ); + + await using ObsWebSocketClient owned = client; + SetupSilentTransport(connection); + + Exception? failure = await PumpForFailureAsync(client.ConnectAsync(), time); + + Assert.IsNotNull(failure, "a handshake that never completes must fail the connect"); + Assert.IsFalse(client.IsConnected); + } + + [TestMethod] + [Timeout(TestTimeout)] + public async Task ConnectAsync_RefusedWithAutoReconnectOff_TriesOnce() + { + FakeTimeProvider time = new(); + ( + ObsWebSocketClient client, + Mock connection, + Mock _, + Mock __ + ) = TestUtils.BuildMockedClientInfrastructure( + options => + { + options.ServerUri = s_uri; + options.AutoReconnectEnabled = false; + options.MaxReconnectAttempts = 0; + }, + timeProvider: time + ); + + await using ObsWebSocketClient owned = client; + + int attempts = 0; + _ = connection + .Setup(c => c.ConnectAsync(s_uri, It.IsAny())) + .Callback(() => attempts++) + .ThrowsAsync(new WebSocketException("refused")); + + Exception? failure = await PumpForFailureAsync(client.ConnectAsync(), time); + + Assert.IsNotNull(failure); + Assert.AreEqual(1, attempts, "auto reconnect is off, so exactly one attempt is expected"); + Assert.IsFalse(client.IsConnected); + } + + [TestMethod] + [Timeout(TestTimeout)] + public async Task ConnectAsync_RefusedWithAutoReconnectOn_Retries() + { + FakeTimeProvider time = new(); + ( + ObsWebSocketClient client, + Mock connection, + Mock _, + Mock __ + ) = TestUtils.BuildMockedClientInfrastructure( + options => + { + options.ServerUri = s_uri; + options.AutoReconnectEnabled = true; + options.MaxReconnectAttempts = 2; + options.InitialReconnectDelayMs = 1; + options.MaxReconnectDelayMs = 2; + }, + timeProvider: time + ); + + await using ObsWebSocketClient owned = client; + + int attempts = 0; + _ = connection + .Setup(c => c.ConnectAsync(s_uri, It.IsAny())) + .Callback(() => attempts++) + .ThrowsAsync(new WebSocketException("refused")); + + Exception? failure = await PumpForFailureAsync(client.ConnectAsync(), time); + + Assert.IsNotNull(failure); + Assert.IsGreaterThan(1, attempts, "the retry limit allows more than the first attempt"); + Assert.IsFalse(client.IsConnected); + } + + [TestMethod] + [Timeout(TestTimeout)] + public async Task DisconnectAsync_NeverConnected_DoesNothing() + { + ( + ObsWebSocketClient client, + Mock _, + Mock __, + Mock ___ + ) = TestUtils.BuildMockedClientInfrastructure(options => options.ServerUri = s_uri); + + await using ObsWebSocketClient owned = client; + + await client.DisconnectAsync(); + + Assert.IsFalse(client.IsConnected); + } + + [TestMethod] + [Timeout(TestTimeout)] + public async Task ReidentifyAsync_NotConnected_Throws() + { + ( + ObsWebSocketClient client, + Mock _, + Mock __, + Mock ___ + ) = TestUtils.BuildMockedClientInfrastructure(options => options.ServerUri = s_uri); + + await using ObsWebSocketClient owned = client; + + _ = await Assert.ThrowsExactlyAsync(() => + client.ReidentifyAsync(null) + ); + } + + [TestMethod] + [Timeout(TestTimeout)] + public async Task CallAsync_NotConnected_Throws() + { + ( + ObsWebSocketClient client, + Mock _, + Mock __, + Mock ___ + ) = TestUtils.BuildMockedClientInfrastructure(options => options.ServerUri = s_uri); + + await using ObsWebSocketClient owned = client; + + _ = await Assert.ThrowsExactlyAsync(() => + client.CallAsync("GetVersion") + ); + } + + [TestMethod] + [Timeout(TestTimeout)] + public async Task CallAsync_SendThrows_SurfacesToCaller() + { + ( + ObsWebSocketClient client, + Mock _, + Mock connection + ) = TestUtils.SetupConnectedClientForceState(); + + await using ObsWebSocketClient owned = client; + + _ = connection + .Setup(c => + c.SendAsync( + It.IsAny>(), + It.IsAny(), + true, + It.IsAny() + ) + ) + .ThrowsAsync(new WebSocketException("send failed")); + + _ = await Assert.ThrowsExactlyAsync(() => + client.CallAsync("GetVersion") + ); + } + + [TestMethod] + [Timeout(TestTimeout)] + [DataRow(4009, true)] + [DataRow(4011, false)] + public async Task HandleServerClose_CloseCode_OnlyAuthenticationFailureIsFatal( + int closeCode, + bool fatal + ) + { + ( + ObsWebSocketClient client, + Mock _, + Mock connection + ) = TestUtils.SetupConnectedClientForceState(); + + await using ObsWebSocketClient owned = client; + + ObsConnectionContext context = TestUtils.GetPrivateField( + client, + "_connection" + )!; + connection.SetupGet(c => c.State).Returns(WebSocketState.CloseReceived); + connection.SetupGet(c => c.CloseStatus).Returns((WebSocketCloseStatus)closeCode); + connection.SetupGet(c => c.CloseStatusDescription).Returns("closed by the server"); + + Action handleServerClose = TestUtils.GetPrivateMethodDelegate< + Action + >(client, "HandleServerClose")!; + handleServerClose(context); + + Exception failure = await GetFaultAsync(context.Identified.Task); + + // Only a rejected identify may stop the reconnect loop; every other close is retryable. + if (fatal) + { + Assert.IsInstanceOfType(failure); + } + else + { + Assert.IsNotInstanceOfType(failure); + Assert.IsInstanceOfType(failure); + } + } + + private static async Task GetFaultAsync(Task task) + { + try + { + await task; + } + catch (Exception error) + { + return error; + } + + throw new AssertFailedException("the handshake was expected to fail"); + } + + [TestMethod] + [Timeout(TestTimeout)] + public async Task WaitForResponseAsync_FaultedThenLifetimeCancelled_SurfacesFault() + { + ( + ObsWebSocketClient client, + Mock _, + Mock __ + ) = TestUtils.SetupConnectedClientForceState(); + + await using ObsWebSocketClient owned = client; + + Func, int, string, CancellationToken, Task> wait = + TestUtils.GetPrivateMethodDelegate< + Func, int, string, CancellationToken, Task> + >(client, "WaitForResponseAsync")!; + + // A dropped connection faults the pending request and then cancels the client lifetime. + // The fault reaches the waiter a thread-pool turn later, the cancellation at once. + TaskCompletionSource pending = new( + TaskCreationOptions.RunContinuationsAsynchronously + ); + using CancellationTokenSource lifetime = new(); + Task waiting = wait(pending, 30_000, "Request 'GetVersion'", lifetime.Token); + + ObsWebSocketException lost = new("Connection closed by server."); + _ = pending.TrySetException(lost); + await lifetime.CancelAsync(); + + ObsWebSocketException surfaced = await Assert.ThrowsAsync(() => + waiting + ); + Assert.AreSame(lost, surfaced.InnerException, "the cause must not be lost to the cancel"); + } + + [TestMethod] + [Timeout(TestTimeout)] + public async Task WaitForResponseAsync_CallerCancelled_ThrowsOperationCanceled() + { + ( + ObsWebSocketClient client, + Mock _, + Mock __ + ) = TestUtils.SetupConnectedClientForceState(); + + await using ObsWebSocketClient owned = client; + + Func, int, string, CancellationToken, Task> wait = + TestUtils.GetPrivateMethodDelegate< + Func, int, string, CancellationToken, Task> + >(client, "WaitForResponseAsync")!; + + TaskCompletionSource pending = new( + TaskCreationOptions.RunContinuationsAsynchronously + ); + using CancellationTokenSource caller = new(); + Task waiting = wait(pending, 30_000, "Request 'GetVersion'", caller.Token); + + await caller.CancelAsync(); + + _ = await Assert.ThrowsAsync(() => waiting); + } + + [TestMethod] + [Timeout(TestTimeout)] + public async Task DisposeAsync_CalledTwice_DoesNotThrow() + { + ( + ObsWebSocketClient client, + Mock _, + Mock __, + Mock ___ + ) = TestUtils.BuildMockedClientInfrastructure(options => options.ServerUri = s_uri); + + await client.DisposeAsync(); + await client.DisposeAsync(); + } +} diff --git a/ObsWebSocket.Tests/ConnectionLifecycleTests.cs b/ObsWebSocket.Tests/ConnectionLifecycleTests.cs new file mode 100644 index 0000000..09fd7a6 --- /dev/null +++ b/ObsWebSocket.Tests/ConnectionLifecycleTests.cs @@ -0,0 +1,497 @@ +using System.Net.WebSockets; +using Moq; +using ObsWebSocket.Core; +using ObsWebSocket.Core.Networking; +using ObsWebSocket.Core.Protocol.Generated; +using ObsWebSocket.Core.Serialization; + +namespace ObsWebSocket.Tests; + +/// +/// The per-connection state: what a connection context owns, when it signals its end, and the +/// delay curve a reconnect follows. +/// +[TestClass] +public sealed class ConnectionLifecycleTests +{ + private const int TestTimeout = 30_000; + private static readonly Uri s_uri = new("ws://testhost:4455"); + + private static ObsWebSocketClientOptions Options( + Action? configure = null + ) + { + ObsWebSocketClientOptions options = new() { ServerUri = s_uri }; + configure?.Invoke(options); + return options; + } + + private static ObsConnectionContext CreateContext( + Mock transport, + CancellationToken lifetime = default, + Action? configure = null + ) => + new( + transport.Object, + Mock.Of(), + ObsConnectionSettings.Capture(Options(configure)), + lifetime + ); + + #region Context + + [TestMethod] + [Timeout(TestTimeout)] + public async Task Context_New_ExposesPartsAndIsOpen() + { + Mock transport = new(); + IWebSocketMessageSerializer serializer = Mock.Of(); + ObsConnectionSettings settings = ObsConnectionSettings.Capture(Options()); + + await using ObsConnectionContext context = new( + transport.Object, + serializer, + settings, + CancellationToken.None + ); + + Assert.AreSame(transport.Object, context.Transport); + Assert.AreSame(serializer, context.Serializer); + Assert.AreSame(settings, context.Settings); + Assert.IsFalse(context.ConnectionClosed.IsCancellationRequested); + Assert.IsFalse(context.Hello.Task.IsCompleted); + Assert.IsFalse(context.Identified.Task.IsCompleted); + Assert.IsNull(context.ReceiveTask); + } + + [TestMethod] + [Timeout(TestTimeout)] + public async Task Close_Open_CancelsTokenAndTearsDownSocket() + { + Mock transport = new(); + await using ObsConnectionContext context = CreateContext(transport); + + context.Close(); + + Assert.IsTrue(context.ConnectionClosed.IsCancellationRequested); + transport.Verify(t => t.Abort(), Times.Once); + transport.Verify(t => t.Dispose(), Times.Once); + } + + [TestMethod] + [Timeout(TestTimeout)] + public async Task Close_SocketFaultsOnTeardown_DoesNotThrow() + { + Mock transport = new(); + transport.Setup(t => t.Abort()).Throws(new WebSocketException("already faulted")); + transport.Setup(t => t.Dispose()).Throws(new ObjectDisposedException("socket")); + + await using ObsConnectionContext context = CreateContext(transport); + + context.Close(); + + Assert.IsTrue(context.ConnectionClosed.IsCancellationRequested); + } + + [TestMethod] + [Timeout(TestTimeout)] + public async Task Context_ClientLifetimeCancelled_SignalsClosed() + { + using CancellationTokenSource lifetime = new(); + Mock transport = new(); + await using ObsConnectionContext context = CreateContext(transport, lifetime.Token); + + await lifetime.CancelAsync(); + + Assert.IsTrue(context.ConnectionClosed.IsCancellationRequested); + } + + [TestMethod] + [Timeout(TestTimeout)] + public async Task Context_LifetimeAlreadyCancelled_StartsClosed() + { + using CancellationTokenSource lifetime = new(); + await lifetime.CancelAsync(); + + Mock transport = new(); + await using ObsConnectionContext context = CreateContext(transport, lifetime.Token); + + Assert.IsTrue(context.ConnectionClosed.IsCancellationRequested); + } + + [TestMethod] + [Timeout(TestTimeout)] + public async Task DisposeAsync_WithReceiveLoop_WaitsForIt() + { + Mock transport = new(); + ObsConnectionContext context = CreateContext(transport); + + TaskCompletionSource loopFinished = new(TaskCreationOptions.RunContinuationsAsynchronously); + context.ReceiveTask = Task.Run(async () => + { + try + { + await Task.Delay(Timeout.Infinite, context.ConnectionClosed); + } + catch (OperationCanceledException) + { + loopFinished.SetResult(); + } + }); + + await context.DisposeAsync(); + + Assert.IsTrue(loopFinished.Task.IsCompletedSuccessfully); + Assert.IsTrue(context.ReceiveTask.IsCompleted); + } + + [TestMethod] + [Timeout(TestTimeout)] + public async Task DisposeAsync_ReceiveLoopFaulted_DoesNotThrow() + { + Mock transport = new(); + ObsConnectionContext context = CreateContext(transport); + context.ReceiveTask = Task.FromException(new WebSocketException("socket died")); + + await context.DisposeAsync(); + + transport.Verify(t => t.Abort(), Times.Once); + } + + [TestMethod] + [Timeout(TestTimeout)] + public async Task DisposeAsync_CalledTwice_TearsDownOnce() + { + Mock transport = new(); + ObsConnectionContext context = CreateContext(transport); + + await context.DisposeAsync(); + await context.DisposeAsync(); + + transport.Verify(t => t.Abort(), Times.Once); + transport.Verify(t => t.Dispose(), Times.Once); + } + + [TestMethod] + [Timeout(TestTimeout)] + public async Task Close_AfterDispose_DoesNotThrow() + { + Mock transport = new(); + ObsConnectionContext context = CreateContext(transport); + + await context.DisposeAsync(); + context.Close(); + + transport.Verify(t => t.Abort(), Times.Exactly(2)); + } + + [TestMethod] + [Timeout(TestTimeout)] + public async Task Identified_Replaced_KeepsHelloAndAbandonsOldWaiter() + { + Mock transport = new(); + await using ObsConnectionContext context = CreateContext(transport); + + TaskCompletionSource first = context.Identified; + context.Hello.SetResult(new object()); + + TaskCompletionSource second = new( + TaskCreationOptions.RunContinuationsAsynchronously + ); + context.Identified = second; + second.SetResult(new object()); + + Assert.AreNotSame(first, context.Identified); + Assert.IsFalse(first.Task.IsCompleted, "the replaced waiter is abandoned, not completed"); + Assert.IsTrue(context.Hello.Task.IsCompletedSuccessfully); + Assert.IsTrue(context.Identified.Task.IsCompletedSuccessfully); + } + + #endregion + + #region Settings + + [TestMethod] + [Timeout(TestTimeout)] + public void Capture_Options_CopiesConnectionValues() + { + ObsConnectionSettings settings = ObsConnectionSettings.Capture( + Options(options => + { + options.Password = "secret"; + options.Format = SerializationFormat.MsgPack; + options.EventSubscriptions = EventSubscription.Scenes; + options.HandshakeTimeoutMs = 1234; + options.AutoReconnectEnabled = true; + options.MaxReconnectAttempts = 7; + options.InitialReconnectDelayMs = 250; + options.MaxReconnectDelayMs = 9000; + options.ReconnectBackoffMultiplier = 2.5; + }) + ); + + Assert.AreEqual(s_uri, settings.ServerUri); + Assert.AreEqual("secret", settings.Password); + Assert.AreEqual(SerializationFormat.MsgPack, settings.Format); + Assert.AreEqual(EventSubscription.Scenes, settings.EventSubscriptions); + Assert.AreEqual(1234, settings.HandshakeTimeoutMs); + Assert.IsTrue(settings.AutoReconnectEnabled); + Assert.AreEqual(7, settings.MaxReconnectAttempts); + Assert.AreEqual(250, settings.InitialReconnectDelayMs); + Assert.AreEqual(9000, settings.MaxReconnectDelayMs); + Assert.AreEqual(2.5, settings.ReconnectBackoffMultiplier); + } + + [TestMethod] + [Timeout(TestTimeout)] + public void Capture_NoSubscriptions_DefaultsToAll() + { + ObsConnectionSettings settings = ObsConnectionSettings.Capture( + Options(options => options.EventSubscriptions = null) + ); + + Assert.AreEqual(EventSubscription.All, settings.EventSubscriptions); + } + + [TestMethod] + [Timeout(TestTimeout)] + public void Capture_MultiplierBelowOne_ClampsCopyOnly() + { + ObsWebSocketClientOptions options = Options(o => o.ReconnectBackoffMultiplier = 0.25); + + ObsConnectionSettings settings = ObsConnectionSettings.Capture(options); + + Assert.AreEqual(1.0, settings.ReconnectBackoffMultiplier); + Assert.AreEqual( + 0.25, + options.ReconnectBackoffMultiplier, + "clamping must not write back to the monitored instance" + ); + } + + [TestMethod] + [Timeout(TestTimeout)] + public void Capture_NoServerUri_Throws() + { + _ = Assert.ThrowsExactly(() => + ObsConnectionSettings.Capture(new ObsWebSocketClientOptions()) + ); + _ = Assert.ThrowsExactly(() => ObsConnectionSettings.Capture(null!)); + } + + [TestMethod] + [Timeout(TestTimeout)] + [DataRow("uri")] + [DataRow("password")] + [DataRow("format")] + [DataRow("subscriptions")] + public void RequiresNewConnection_SocketValueChanged_ReturnsTrue(string change) + { + ObsConnectionSettings settings = ObsConnectionSettings.Capture( + Options(options => + { + options.Password = "secret"; + options.EventSubscriptions = EventSubscription.All; + }) + ); + + ObsWebSocketClientOptions updated = Options(options => + { + options.Password = "secret"; + options.EventSubscriptions = EventSubscription.All; + switch (change) + { + case "uri": + options.ServerUri = new Uri("ws://elsewhere:4455"); + break; + case "password": + options.Password = "other"; + break; + case "format": + options.Format = SerializationFormat.MsgPack; + break; + default: + options.EventSubscriptions = EventSubscription.Scenes; + break; + } + }); + + Assert.IsTrue(settings.RequiresNewConnection(updated)); + } + + [TestMethod] + [Timeout(TestTimeout)] + public void RequiresNewConnection_PerCallValueChanged_ReturnsFalse() + { + ObsConnectionSettings settings = ObsConnectionSettings.Capture( + Options(options => options.Password = "secret") + ); + + ObsWebSocketClientOptions updated = Options(options => + { + options.Password = "secret"; + options.HandshakeTimeoutMs = 99_000; + options.AutoReconnectEnabled = !settings.AutoReconnectEnabled; + options.MaxReconnectAttempts = 42; + options.InitialReconnectDelayMs = 4321; + options.MaxReconnectDelayMs = 54_321; + options.ReconnectBackoffMultiplier = 3.0; + options.RequestTimeoutMs = 1; + }); + + Assert.IsFalse(settings.RequiresNewConnection(updated)); + _ = Assert.ThrowsExactly(() => + settings.RequiresNewConnection(null!) + ); + } + + #endregion + + #region Reconnect delays + + [TestMethod] + [Timeout(TestTimeout)] + public async Task GetDelayAsync_Disabled_ReturnsZero() + { + Assert.AreEqual(TimeSpan.Zero, await ReconnectDelays.Disabled.GetDelayAsync(0)); + Assert.AreEqual(TimeSpan.Zero, await ReconnectDelays.Disabled.GetDelayAsync(9)); + } + + [TestMethod] + [Timeout(TestTimeout)] + public async Task GetDelayAsync_SuccessiveAttempts_GrowsToCeiling() + { + ReconnectDelays delays = new( + Options(options => + { + options.InitialReconnectDelayMs = 100; + options.MaxReconnectDelayMs = 800; + options.ReconnectBackoffMultiplier = 2.0; + }) + ); + + // Jitter spreads each delay over 75 to 125 percent of its nominal value, so the + // assertions are on the band rather than the exact number. + await AssertWithinJitterAsync(delays, retryIndex: 0, nominalMs: 100); + await AssertWithinJitterAsync(delays, retryIndex: 1, nominalMs: 200); + await AssertWithinJitterAsync(delays, retryIndex: 2, nominalMs: 400); + await AssertWithinJitterAsync(delays, retryIndex: 3, nominalMs: 800); + await AssertWithinJitterAsync(delays, retryIndex: 10, nominalMs: 800); + } + + [TestMethod] + [Timeout(TestTimeout)] + public async Task GetDelayAsync_NegativeIndex_TreatedAsFirst() + { + ReconnectDelays delays = new( + Options(options => + { + options.InitialReconnectDelayMs = 100; + options.MaxReconnectDelayMs = 800; + options.ReconnectBackoffMultiplier = 2.0; + }) + ); + + await AssertWithinJitterAsync(delays, retryIndex: -5, nominalMs: 100); + } + + [TestMethod] + [Timeout(TestTimeout)] + public async Task GetDelayAsync_MultiplierOne_RepeatsInitialDelay() + { + ReconnectDelays delays = new( + Options(options => + { + options.InitialReconnectDelayMs = 500; + options.MaxReconnectDelayMs = 5000; + options.ReconnectBackoffMultiplier = 1.0; + }) + ); + + await AssertWithinJitterAsync(delays, retryIndex: 0, nominalMs: 500); + await AssertWithinJitterAsync(delays, retryIndex: 4, nominalMs: 500); + } + + [TestMethod] + [Timeout(TestTimeout)] + public async Task GetDelayAsync_FromSettings_UsesTheirCurve() + { + ObsConnectionSettings settings = ObsConnectionSettings.Capture( + Options(options => + { + options.InitialReconnectDelayMs = 200; + options.MaxReconnectDelayMs = 400; + options.ReconnectBackoffMultiplier = 2.0; + }) + ); + + ReconnectDelays delays = new(settings); + + await AssertWithinJitterAsync(delays, retryIndex: 0, nominalMs: 200); + await AssertWithinJitterAsync(delays, retryIndex: 5, nominalMs: 400); + } + + [TestMethod] + [Timeout(TestTimeout)] + public async Task GetDelayAsync_ZeroDelay_StaysZero() + { + ReconnectDelays delays = new( + Options(options => + { + options.InitialReconnectDelayMs = 0; + options.MaxReconnectDelayMs = 0; + }) + ); + + Assert.AreEqual(TimeSpan.Zero, await delays.GetDelayAsync(0)); + Assert.AreEqual(TimeSpan.Zero, await delays.GetDelayAsync(3)); + } + + [TestMethod] + [Timeout(TestTimeout)] + public void ReconnectDelays_NullSource_Throws() + { + _ = Assert.ThrowsExactly(() => + new ReconnectDelays((ObsWebSocketClientOptions)null!) + ); + _ = Assert.ThrowsExactly(() => + new ReconnectDelays((ObsConnectionSettings)null!) + ); + } + + [TestMethod] + [Timeout(TestTimeout)] + public async Task GetDelayAsync_CancelledToken_StillReturnsDelay() + { + ReconnectDelays delays = new(Options(options => options.InitialReconnectDelayMs = 100)); + using CancellationTokenSource cancelled = new(); + await cancelled.CancelAsync(); + + // The lookup is pure arithmetic, so a cancelled token is simply carried through. + TimeSpan delay = await delays.GetDelayAsync(0, cancelled.Token); + + Assert.IsGreaterThan(TimeSpan.Zero, delay); + } + + private static async Task AssertWithinJitterAsync( + ReconnectDelays delays, + int retryIndex, + double nominalMs + ) + { + TimeSpan delay = await delays.GetDelayAsync(retryIndex); + + Assert.IsGreaterThanOrEqualTo( + TimeSpan.FromMilliseconds(nominalMs * 0.75), + delay, + $"retry {retryIndex} fell below the jitter band for {nominalMs} ms" + ); + Assert.IsLessThanOrEqualTo( + TimeSpan.FromMilliseconds(nominalMs * 1.25), + delay, + $"retry {retryIndex} exceeded the jitter band for {nominalMs} ms" + ); + } + + #endregion +} diff --git a/ObsWebSocket.Tests/EventDispatchCoverageTests.cs b/ObsWebSocket.Tests/EventDispatchCoverageTests.cs new file mode 100644 index 0000000..01696aa --- /dev/null +++ b/ObsWebSocket.Tests/EventDispatchCoverageTests.cs @@ -0,0 +1,131 @@ +using System.Reflection; +using System.Text.Json; +using System.Text.Json.Nodes; +using ObsWebSocket.Core; +using ObsWebSocket.Tests.Fakes; + +namespace ObsWebSocket.Tests; + +/// +/// Every event the protocol defines reaches its public event through the real receive path. The +/// receive loop drops what it cannot place, so an event missing from dispatch would otherwise go +/// unnoticed. +/// +[TestClass] +public sealed class EventDispatchCoverageTests +{ + private const int TestTimeout = 30_000; + + private static readonly JsonElement[] s_events = ReadEvents(); + + /// String fields the client reads as enums, so a placeholder would not parse. + private static readonly Dictionary s_enumSamples = new(StringComparer.Ordinal) + { + ["outputState"] = "OBS_WEBSOCKET_OUTPUT_STARTED", + ["mediaAction"] = "OBS_WEBSOCKET_MEDIA_INPUT_ACTION_PLAY", + ["monitorType"] = "OBS_MONITORING_TYPE_NONE", + }; + + /// Object fields the client models as a typed record with required members. + private static readonly Dictionary s_objectSamples = new(StringComparer.Ordinal) + { + ["sceneItemTransform"] = + """{"positionX":0,"positionY":0,"rotation":0,"scaleX":1,"scaleY":1,"width":0,"height":0,"sourceWidth":0,"sourceHeight":0,"alignment":5,"boundsType":"OBS_BOUNDS_NONE","boundsAlignment":0,"boundsWidth":0,"boundsHeight":0,"cropLeft":0,"cropTop":0,"cropRight":0,"cropBottom":0}""", + }; + + public static IEnumerable EventTypes => + s_events.Select(e => new object[] { e.GetProperty("eventType").GetString()! }); + + [TestMethod] + public void Protocol_Loaded_DefinesEvents() => Assert.IsGreaterThan(50, s_events.Length); + + [TestMethod] + [Timeout(TestTimeout)] + [DynamicData(nameof(EventTypes))] + public async Task EventDispatch_EveryProtocolEvent_RaisesPublicEvent(string eventType) + { + await using FakeObsClient fake = await FakeObsClient.ConnectAsync(new FakeObsServer()); + + EventInfo publicEvent = + typeof(ObsWebSocketClient).GetEvent(eventType) + ?? throw new AssertFailedException($"No public event is declared for {eventType}."); + + TaskCompletionSource raised = new( + TaskCreationOptions.RunContinuationsAsynchronously + ); + Delegate handler = Delegate.CreateDelegate( + publicEvent.EventHandlerType!, + raised, + typeof(EventDispatchCoverageTests) + .GetMethod(nameof(Capture), BindingFlags.NonPublic | BindingFlags.Static)! + .MakeGenericMethod(publicEvent.EventHandlerType!.GetGenericArguments()[0]) + ); + publicEvent.AddEventHandler(fake.Client, handler); + + fake.Server.RaiseEvent(eventType, SamplePayload(eventType).ToJsonString()); + + object args = await raised.Task.WaitAsync(TimeSpan.FromSeconds(10)); + Assert.IsInstanceOfType(args, publicEvent.EventHandlerType.GetGenericArguments()[0]); + } + + private static void Capture(TaskCompletionSource raised, object? _, TArgs e) + where TArgs : notnull => raised.TrySetResult(e); + + /// Builds an event payload that satisfies every field the protocol lists. + private static JsonObject SamplePayload(string eventType) + { + JsonElement definition = s_events.Single(e => + e.GetProperty("eventType").GetString() == eventType + ); + JsonObject payload = []; + + if (!definition.TryGetProperty("dataFields", out JsonElement fields)) + { + return payload; + } + + foreach (JsonElement field in fields.EnumerateArray()) + { + string name = field.GetProperty("valueName").GetString()!; + string type = field.GetProperty("valueType").GetString()!; + + // Dotted names describe a nested object. + JsonObject target = payload; + string[] path = name.Split('.'); + foreach (string segment in path[..^1]) + { + target = + target[segment] as JsonObject + ?? (JsonObject)(target[segment] = new JsonObject()); + } + + string leaf = path[^1]; + target[leaf] = type switch + { + "Number" => 1, + "Boolean" => true, + "Object" => s_objectSamples.TryGetValue(leaf, out string? shape) + ? JsonNode.Parse(shape) + : new JsonObject(), + _ when type.StartsWith("Array", StringComparison.Ordinal) => new JsonArray(), + _ => s_enumSamples.TryGetValue(leaf, out string? sample) ? sample : "sample", + }; + } + + return payload; + } + + private static JsonElement[] ReadEvents() + { + string path = Path.Combine(AppContext.BaseDirectory, "protocol.json"); + using JsonDocument document = JsonDocument.Parse(File.ReadAllText(path)); + return + [ + .. document + .RootElement.GetProperty("events") + .EnumerateArray() + .Select(e => e.Clone()) + .OrderBy(e => e.GetProperty("eventType").GetString(), StringComparer.Ordinal), + ]; + } +} diff --git a/ObsWebSocket.Tests/FakeServerHandshakeTests.cs b/ObsWebSocket.Tests/FakeServerHandshakeTests.cs new file mode 100644 index 0000000..bbdd31b --- /dev/null +++ b/ObsWebSocket.Tests/FakeServerHandshakeTests.cs @@ -0,0 +1,171 @@ +using ObsWebSocket.Core; +using ObsWebSocket.Core.Protocol; +using ObsWebSocket.Core.Protocol.Generated; +using ObsWebSocket.Core.Protocol.Responses; +using ObsWebSocket.Tests.Fakes; + +namespace ObsWebSocket.Tests; + +/// +/// The handshake and request path driven end to end over the in-memory OBS: the same code a live +/// connection runs, without a live connection. +/// +[TestClass] +public sealed class FakeServerHandshakeTests +{ + private const int TestTimeout = 30_000; + + [TestMethod] + [Timeout(TestTimeout)] + public async Task ConnectAsync_NoAuthentication_ConnectsAndServesRequests() + { + FakeObsServer server = new(); + _ = server.Returns( + "GetVersion", + """{"obsVersion":"32.2.2","obsWebSocketVersion":"5.7.0","rpcVersion":1,"availableRequests":[],"supportedImageFormats":[],"platform":"windows","platformDescription":"Windows 11"}""" + ); + + await using FakeObsClient fake = await FakeObsClient.ConnectAsync(server); + + Assert.IsTrue(fake.Client.IsConnected); + Assert.AreEqual(1, fake.Client.NegotiatedRpcVersion); + + Assert.AreEqual("32.2.2", (await fake.Client.General.GetVersionAsync()).ObsVersion); + CollectionAssert.Contains(server.Requests.ToList(), "GetVersion"); + } + + [TestMethod] + [Timeout(TestTimeout)] + public async Task ConnectAsync_WithPassword_AnswersChallenge() + { + FakeObsServer server = new() { Password = "secret" }; + + await using FakeObsClient fake = await FakeObsClient.ConnectAsync( + server, + options => options.Password = "secret" + ); + + Assert.IsTrue(fake.Client.IsConnected); + } + + [TestMethod] + [Timeout(TestTimeout)] + public async Task ConnectAsync_PasswordRequiredButMissing_ThrowsAuthenticationFailure() + { + FakeObsServer server = new() { Password = "secret" }; + await using FakeObsClient fake = FakeObsClient.Build(server); + + _ = await Assert.ThrowsExactlyAsync(() => + fake.Client.ConnectAsync() + ); + Assert.IsFalse(fake.Client.IsConnected); + } + + [TestMethod] + [Timeout(TestTimeout)] + public async Task ConnectAsync_IdentifyRejected_ThrowsAuthenticationFailureWithoutRetry() + { + FakeObsServer server = new() + { + RejectIdentifyWith = (int)WebSocketCloseCode.AuthenticationFailed, + }; + await using FakeObsClient fake = FakeObsClient.Build( + server, + options => + { + options.Password = "wrong"; + options.AutoReconnectEnabled = true; + options.MaxReconnectAttempts = 5; + options.InitialReconnectDelayMs = 1; + options.MaxReconnectDelayMs = 1; + } + ); + + _ = await Assert.ThrowsExactlyAsync(() => + fake.Client.ConnectAsync() + ); + + Assert.IsFalse(fake.Client.IsConnected); + } + + [TestMethod] + [Timeout(TestTimeout)] + public async Task CallAsync_ObsReportsFailure_ThrowsWithStatus() + { + FakeObsServer server = new(); + _ = server.Fails("GetVersion", (int)RequestStatusCode.ResourceNotFound); + + await using FakeObsClient fake = await FakeObsClient.ConnectAsync(server); + + ObsWebSocketRequestException error = + await Assert.ThrowsExactlyAsync(() => + fake.Client.General.GetVersionAsync() + ); + + Assert.AreEqual(RequestStatusCode.ResourceNotFound, error.StatusCode); + } + + [TestMethod] + [Timeout(TestTimeout)] + public async Task EventDispatch_KnownEvent_RaisesGeneratedEvent() + { + FakeObsServer server = new(); + await using FakeObsClient fake = await FakeObsClient.ConnectAsync(server); + + TaskCompletionSource sceneName = new( + TaskCreationOptions.RunContinuationsAsynchronously + ); + fake.Client.CurrentProgramSceneChanged += (_, e) => + sceneName.TrySetResult(e.EventData.SceneName); + + server.RaiseEvent( + "CurrentProgramSceneChanged", + """{"sceneName":"Live","sceneUuid":"00000000-0000-0000-0000-000000000001"}""" + ); + + Assert.AreEqual("Live", await sceneName.Task.WaitAsync(TimeSpan.FromSeconds(10))); + } + + [TestMethod] + [Timeout(TestTimeout)] + public async Task ReceiveLoop_ServerCloses_Disconnects() + { + FakeObsServer server = new(); + await using FakeObsClient fake = await FakeObsClient.ConnectAsync(server); + + TaskCompletionSource disconnected = new(TaskCreationOptions.RunContinuationsAsynchronously); + fake.Client.Disconnected += (_, _) => disconnected.TrySetResult(); + + server.CloseFromServer((int)WebSocketCloseCode.SessionInvalidated); + + await disconnected.Task.WaitAsync(TimeSpan.FromSeconds(10)); + Assert.IsFalse(fake.Client.IsConnected); + } + + [TestMethod] + [Timeout(TestTimeout)] + public async Task CallBatchAsync_MixedResults_ReturnsEveryItemInOrder() + { + FakeObsServer server = new(); + _ = server.Returns( + "GetVersion", + """{"obsVersion":"32.2.2","obsWebSocketVersion":"5.7.0","rpcVersion":1,"availableRequests":[],"supportedImageFormats":[],"platform":"windows","platformDescription":"Windows 11"}""" + ); + _ = server.Fails("GetSceneList", (int)RequestStatusCode.ResourceNotFound); + + await using FakeObsClient fake = await FakeObsClient.ConnectAsync(server); + + List> results = await fake.Client.CallBatchAsync( + [new("GetVersion"), new("GetSceneList")], + haltOnFailure: false + ); + + Assert.HasCount(2, results); + Assert.AreEqual("GetVersion", results[0].RequestType); + Assert.IsTrue(results[0].RequestStatus.Result); + Assert.IsFalse(results[1].RequestStatus.Result); + Assert.IsFalse(results.AllSucceeded()); + Assert.HasCount(1, results.GetFailures().ToList()); + Assert.AreEqual("32.2.2", results[0].GetRequiredData().ObsVersion); + } +} diff --git a/ObsWebSocket.Tests/Fakes/FakeObsClient.cs b/ObsWebSocket.Tests/Fakes/FakeObsClient.cs new file mode 100644 index 0000000..323ab52 --- /dev/null +++ b/ObsWebSocket.Tests/Fakes/FakeObsClient.cs @@ -0,0 +1,74 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using ObsWebSocket.Core; +using ObsWebSocket.Core.Networking; + +namespace ObsWebSocket.Tests.Fakes; + +/// +/// A client connected to a through the ordinary DI registration, so +/// the handshake, receive loop and request correlation are the real ones. +/// +internal sealed class FakeObsClient : IAsyncDisposable +{ + private readonly ServiceProvider _provider; + + private FakeObsClient(ServiceProvider provider, FakeObsServer server, ObsWebSocketClient client) + { + _provider = provider; + Server = server; + Client = client; + } + + /// The OBS the client is talking to. + public FakeObsServer Server { get; } + + /// The connected client. + public ObsWebSocketClient Client { get; } + + /// Builds a client against without connecting it. + /// The OBS to answer on the wire. + /// Further client options. + public static FakeObsClient Build( + FakeObsServer server, + Action? configure = null + ) + { + ServiceCollection services = new(); + _ = services.AddLogging(builder => builder.SetMinimumLevel(LogLevel.None)); + _ = services.AddObsWebSocketClient(options => + { + options.ServerUri = new Uri("ws://fake-obs:4455"); + options.AutoReconnectEnabled = false; + options.HandshakeTimeoutMs = 5000; + options.RequestTimeoutMs = 5000; + configure?.Invoke(options); + }); + _ = services.AddSingleton(server); + + ServiceProvider provider = services.BuildServiceProvider(); + return new FakeObsClient( + provider, + server, + provider.GetRequiredService() + ); + } + + /// Builds a client against and connects it. + /// The OBS to answer on the wire. + /// Further client options. + /// A token to cancel the connect. + public static async Task ConnectAsync( + FakeObsServer server, + Action? configure = null, + CancellationToken cancellationToken = default + ) + { + FakeObsClient fake = Build(server, configure); + await fake.Client.ConnectAsync(cancellationToken).ConfigureAwait(false); + return fake; + } + + /// + public async ValueTask DisposeAsync() => await _provider.DisposeAsync().ConfigureAwait(false); +} diff --git a/ObsWebSocket.Tests/Fakes/FakeObsServer.cs b/ObsWebSocket.Tests/Fakes/FakeObsServer.cs new file mode 100644 index 0000000..52ede3a --- /dev/null +++ b/ObsWebSocket.Tests/Fakes/FakeObsServer.cs @@ -0,0 +1,364 @@ +using System.Net.WebSockets; +using System.Text; +using System.Text.Json; +using System.Threading.Channels; +using ObsWebSocket.Core.Networking; + +namespace ObsWebSocket.Tests.Fakes; + +/// +/// An OBS that answers on the wire, in memory. It speaks the JSON sub-protocol the client +/// negotiates, so a test drives the real handshake, receive loop and request correlation without +/// a running OBS. +/// +/// +/// Requests are answered by handlers registered with ; anything else gets +/// an empty success. A handler is free to raise events before it returns, which is how the helpers +/// that send a request and then wait for an event are exercised. +/// +internal sealed class FakeObsServer : IWebSocketConnection, IWebSocketConnectionFactory +{ + private Channel _outbound = Channel.CreateUnbounded(); + private readonly Dictionary> _handlers = new( + StringComparer.Ordinal + ); + private readonly List _requests = []; + private readonly List _connectedTo = []; + private readonly Lock _gate = new(); + private readonly ClientWebSocket _optionsCarrier = new(); + private ReadOnlyMemory _pending; + + /// Password the identify payload has to authenticate against, if any. + public string? Password { get; init; } + + /// Close code sent instead of Identified, when the handshake is meant to fail. + public int? RejectIdentifyWith { get; init; } + + /// Leaves a re-identify unanswered, as a stalled OBS would. + public bool IgnoreReidentify { get; set; } + + /// Refuses every connection, as when OBS is not running. + public bool RefuseConnections { get; set; } + + /// The endpoints connected to, in order. + public IReadOnlyList ConnectedTo + { + get + { + using (_gate.EnterScope()) + { + return [.. _connectedTo]; + } + } + } + + /// Request types received, in order. + public IReadOnlyList Requests + { + get + { + using (_gate.EnterScope()) + { + return [.. _requests]; + } + } + } + + /// + public WebSocketState State { get; private set; } = WebSocketState.None; + + /// + public WebSocketCloseStatus? CloseStatus { get; private set; } + + /// + public string? CloseStatusDescription { get; private set; } + + /// + public string? SubProtocol => "obswebsocket.json"; + + /// + public ClientWebSocketOptions Options => _optionsCarrier.Options; + + /// Answers with . + /// The OBS request type to answer. + /// Reads the request data and returns the response. + public FakeObsServer OnRequest(string requestType, Func handler) + { + using (_gate.EnterScope()) + { + _handlers[requestType] = handler; + } + + return this; + } + + /// Answers with a fixed success payload. + /// The OBS request type to answer. + /// The responseData object, as JSON. + public FakeObsServer Returns(string requestType, string responseJson) => + OnRequest(requestType, _ => RequestOutcome.Success(responseJson)); + + /// Answers with a failure status. + /// The OBS request type to answer. + /// The request status code OBS would report. + public FakeObsServer Fails(string requestType, int code) => + OnRequest(requestType, _ => RequestOutcome.Failure(code)); + + /// Pushes a frame to the client exactly as written. + /// The frame body. + public void PushRaw(string message) => Push(message); + + /// Pushes an event to the client. + /// The OBS event type. + /// The eventData object, as JSON. + public void RaiseEvent(string eventType, string eventDataJson = "{}") => + Push( + "{\"op\":5,\"d\":{\"eventType\":\"" + + eventType + + "\",\"eventIntent\":0,\"eventData\":" + + eventDataJson + + "}}" + ); + + /// Closes the connection from the server side. + /// The close code to report. + /// The close reason to report. + public void CloseFromServer(int code, string description = "closed by the server") + { + CloseStatus = (WebSocketCloseStatus)code; + CloseStatusDescription = description; + State = WebSocketState.CloseReceived; + _outbound.Writer.TryWrite([]); + } + + /// + public IWebSocketConnection CreateConnection() => this; + + /// + public Task ConnectAsync(Uri uri, CancellationToken cancellationToken) + { + if (RefuseConnections) + { + throw new WebSocketException(WebSocketError.Faulted, "Connection refused."); + } + + using (_gate.EnterScope()) + { + _connectedTo.Add(uri); + } + + // A reconnect gets a fresh queue: the previous one was completed when that socket went. + if (_outbound.Reader.Completion.IsCompleted) + { + _outbound = Channel.CreateUnbounded(); + } + + _pending = default; + CloseStatus = null; + CloseStatusDescription = null; + State = WebSocketState.Open; + + string authentication = Password is null + ? "null" + : """{"challenge":"challenge","salt":"salt"}"""; + Push( + "{\"op\":0,\"d\":{\"obsWebSocketVersion\":\"5.7.0\",\"rpcVersion\":1,\"authentication\":" + + authentication + + "}}" + ); + + return Task.CompletedTask; + } + + /// + public ValueTask SendAsync( + ReadOnlyMemory buffer, + WebSocketMessageType messageType, + bool endOfMessage, + CancellationToken cancellationToken + ) + { + using JsonDocument document = JsonDocument.Parse(buffer); + JsonElement root = document.RootElement; + int op = root.GetProperty("op").GetInt32(); + JsonElement data = root.GetProperty("d"); + + switch (op) + { + case 3 when IgnoreReidentify: + break; + + case 1 + or 3: // Identify and Reidentify + if (RejectIdentifyWith is { } rejection) + { + CloseFromServer(rejection, "Authentication failed."); + break; + } + + Push("""{"op":2,"d":{"negotiatedRpcVersion":1}}"""); + break; + + case 6: + Respond(data); + break; + + case 8: + RespondToBatch(data); + break; + + default: + break; + } + + return ValueTask.CompletedTask; + } + + /// + public async ValueTask ReceiveAsync( + Memory buffer, + CancellationToken cancellationToken + ) + { + if (_pending.IsEmpty) + { + _pending = await _outbound.Reader.ReadAsync(cancellationToken).ConfigureAwait(false); + } + + if (_pending.IsEmpty) + { + State = WebSocketState.CloseReceived; + return new ValueWebSocketReceiveResult(0, WebSocketMessageType.Close, true); + } + + int count = Math.Min(buffer.Length, _pending.Length); + _pending[..count].CopyTo(buffer); + _pending = _pending[count..]; + + return new ValueWebSocketReceiveResult(count, WebSocketMessageType.Text, _pending.IsEmpty); + } + + /// + public Task CloseAsync( + WebSocketCloseStatus closeStatus, + string? statusDescription, + CancellationToken cancellationToken + ) + { + State = WebSocketState.Closed; + _ = _outbound.Writer.TryComplete(); + return Task.CompletedTask; + } + + /// + public Task CloseOutputAsync( + WebSocketCloseStatus closeStatus, + string? statusDescription, + CancellationToken cancellationToken + ) => CloseAsync(closeStatus, statusDescription, cancellationToken); + + /// + public void Abort() + { + State = WebSocketState.Aborted; + _ = _outbound.Writer.TryComplete(); + } + + /// + public void Dispose() + { + _ = _outbound.Writer.TryComplete(); + _optionsCarrier.Dispose(); + } + + private void Respond(JsonElement data) + { + string requestType = data.GetProperty("requestType").GetString()!; + string requestId = data.GetProperty("requestId").GetString()!; + // Cloned: the document this element points into is gone by the time a handler reads it. + JsonElement? requestData = data.TryGetProperty("requestData", out JsonElement payload) + ? payload.Clone() + : null; + + Func? handler; + using (_gate.EnterScope()) + { + _requests.Add(requestType); + _ = _handlers.TryGetValue(requestType, out handler); + } + + RequestOutcome outcome = handler?.Invoke(requestData) ?? RequestOutcome.Success("{}"); + if (outcome == RequestOutcome.NoReply) + { + return; + } + + Push($$"""{"op":7,"d":{{Result(requestType, requestId, outcome)}}}"""); + } + + private void RespondToBatch(JsonElement data) + { + string batchId = data.GetProperty("requestId").GetString()!; + List results = []; + + foreach (JsonElement item in data.GetProperty("requests").EnumerateArray()) + { + string requestType = item.GetProperty("requestType").GetString()!; + string requestId = item.TryGetProperty("requestId", out JsonElement id) + ? id.GetString()! + : batchId; + JsonElement? requestData = item.TryGetProperty("requestData", out JsonElement payload) + ? payload.Clone() + : null; + + Func? handler; + using (_gate.EnterScope()) + { + _requests.Add(requestType); + _ = _handlers.TryGetValue(requestType, out handler); + } + + RequestOutcome outcome = handler?.Invoke(requestData) ?? RequestOutcome.Success("{}"); + results.Add(Result(requestType, requestId, outcome)); + } + + Push( + "{\"op\":9,\"d\":{\"requestId\":\"" + + batchId + + "\",\"results\":[" + + string.Join(",", results) + + "]}}" + ); + } + + private static string Result(string requestType, string requestId, RequestOutcome outcome) + { + string status = + $$"""{"result":{{(outcome.Code == 100 ? "true" : "false")}},"code":{{outcome.Code}}}"""; + string responseData = outcome.ResponseJson is null + ? "" + : $""","responseData":{outcome.ResponseJson}"""; + + return $$"""{"requestType":"{{requestType}}","requestId":"{{requestId}}","requestStatus":{{status}}{{responseData}}}"""; + } + + private void Push(string message) => + _ = _outbound.Writer.TryWrite(Encoding.UTF8.GetBytes(message)); + + /// What the server answers one request with. + /// The OBS request status code. + /// The response data as JSON, or null for none. + internal readonly record struct RequestOutcome(int Code, string? ResponseJson) + { + /// A successful response carrying . + /// The response data as JSON. + public static RequestOutcome Success(string? responseJson = "{}") => new(100, responseJson); + + /// No response at all, as when OBS goes quiet mid-request. + public static RequestOutcome NoReply { get; } = new(0, null); + + /// A failed response carrying . + /// The OBS request status code. + public static RequestOutcome Failure(int code) => new(code, null); + } +} diff --git a/ObsWebSocket.Tests/FormatterCoverageTests.cs b/ObsWebSocket.Tests/FormatterCoverageTests.cs index 9f7e22f..635a7e1 100644 --- a/ObsWebSocket.Tests/FormatterCoverageTests.cs +++ b/ObsWebSocket.Tests/FormatterCoverageTests.cs @@ -23,7 +23,7 @@ public sealed class FormatterCoverageTests ]; [TestMethod] - public void EveryGeneratedProperty_HasAMessagePackFormatter() + public void GeneratedProperties_Every_HasMessagePackFormatter() { IFormatterResolver resolver = MsgPackMessageSerializer.s_msgPackOptions.Resolver; List missing = []; @@ -69,7 +69,7 @@ PropertyInfo property in generated.GetProperties( /// the specific mistake that made GetCanvasList unreadable. /// [TestMethod] - public void EveryStub_ResolvesBothAloneAndInAList() + public void Stubs_AloneAndInList_Resolve() { IFormatterResolver resolver = MsgPackMessageSerializer.s_msgPackOptions.Resolver; List missing = []; diff --git a/ObsWebSocket.Tests/GroupEventTests.cs b/ObsWebSocket.Tests/GroupEventTests.cs index 52a8b2c..1648eb2 100644 --- a/ObsWebSocket.Tests/GroupEventTests.cs +++ b/ObsWebSocket.Tests/GroupEventTests.cs @@ -15,7 +15,7 @@ private static Delegate[] Handlers(ObsWebSocketClient client, string eventName) TestUtils.GetPrivateField(client, eventName)?.GetInvocationList() ?? []; [TestMethod] - public void AddAndRemoveThroughTheGroup_ReachTheClientsList() + public void GroupEvent_AddAndRemove_ReachClientSubscriptions() { (ObsWebSocketClient client, _, _) = TestUtils.SetupConnectedClientForceState(); static void Handler(object? sender, CurrentProgramSceneChangedEventArgs e) { } @@ -38,7 +38,7 @@ static void Handler(object? sender, CurrentProgramSceneChangedEventArgs e) { } } [TestMethod] - public void TheGroupAndTheClientShareOneSubscriptionList() + public void GroupEvent_SubscribedThroughEither_SharesOneList() { (ObsWebSocketClient client, _, _) = TestUtils.SetupConnectedClientForceState(); static void Handler(object? sender, CurrentProgramSceneChangedEventArgs e) { } @@ -61,7 +61,7 @@ static void Handler(object? sender, CurrentProgramSceneChangedEventArgs e) { } } [TestMethod] - public void EveryCategoryGroupCarriesItsEvents() + public void CategoryGroups_Every_CarriesItsEvents() { // The point of the change: a caller never has to know that some events sit on the client // and some on a group. diff --git a/ObsWebSocket.Tests/GroupHelperFailureTests.cs b/ObsWebSocket.Tests/GroupHelperFailureTests.cs new file mode 100644 index 0000000..6e167f0 --- /dev/null +++ b/ObsWebSocket.Tests/GroupHelperFailureTests.cs @@ -0,0 +1,316 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using ObsWebSocket.Core; +using ObsWebSocket.Core.Protocol.Common.FilterSettings; +using ObsWebSocket.Core.Protocol.Common.InputSettings; +using ObsWebSocket.Core.Protocol.Generated; +using ObsWebSocket.Tests.Fakes; + +namespace ObsWebSocket.Tests; + +/// +/// How the helpers behave when OBS or the caller's types do not cooperate: what they turn into +/// "nothing", what they let through, and what they refuse. +/// +[TestClass] +public sealed class GroupHelperFailureTests +{ + private const int TestTimeout = 30_000; + + #region Typed settings + + [TestMethod] + [Timeout(TestTimeout)] + public async Task GetSettingsAsync_UnknownToObs_ReturnsNull() + { + FakeObsServer server = new(); + _ = server.Fails("GetInputSettings", (int)RequestStatusCode.ResourceNotFound); + _ = server.Fails("GetSourceFilter", (int)RequestStatusCode.ResourceNotFound); + await using FakeObsClient fake = await FakeObsClient.ConnectAsync(server); + + Assert.IsNull( + await fake.Client.Inputs.GetInputSettingsAsync("Absent") + ); + Assert.IsNull( + await fake.Client.Filters.GetSourceFilterSettingsAsync( + "Cam", + "Absent" + ) + ); + } + + [TestMethod] + [Timeout(TestTimeout)] + public async Task GetSettingsAsync_WrongShape_ReturnsNull() + { + FakeObsServer server = new(); + _ = server.Returns( + "GetInputSettings", + """{"inputSettings":{"url":42},"inputKind":"browser_source"}""" + ); + _ = server.Returns( + "GetSourceFilter", + """{"filterEnabled":true,"filterIndex":0,"filterKind":"color_filter_v2","filterSettings":{"opacity":"half"}}""" + ); + await using FakeObsClient fake = await FakeObsClient.ConnectAsync(server); + + Assert.IsNull(await fake.Client.Inputs.GetInputSettingsAsync("Web")); + Assert.IsNull( + await fake.Client.Filters.GetSourceFilterSettingsAsync( + "Cam", + "Grade" + ) + ); + } + + [TestMethod] + [Timeout(TestTimeout)] + public async Task GetInputSettingsAsync_OtherRefusal_Throws() + { + FakeObsServer server = new(); + _ = server.Fails("GetInputSettings", (int)RequestStatusCode.InvalidResourceState); + await using FakeObsClient fake = await FakeObsClient.ConnectAsync(server); + + _ = await Assert.ThrowsExactlyAsync(() => + fake.Client.Inputs.GetInputSettingsAsync("Web") + ); + } + + [TestMethod] + [Timeout(TestTimeout)] + public async Task SetSettingsAsync_Unserializable_ThrowsBeforeSending() + { + FakeObsServer server = new(); + await using FakeObsClient fake = await FakeObsClient.ConnectAsync(server); + ObsWebSocketClient client = fake.Client; + BrokenSettings broken = new(new Unwritable()); + var typeInfo = BrokenSettingsContext.Default.BrokenSettings; + + _ = await Assert.ThrowsExactlyAsync(() => + client.Inputs.SetInputSettingsAsync("Web", broken, typeInfo) + ); + _ = await Assert.ThrowsExactlyAsync(() => + client.Inputs.CreateInputAsync( + "browser_source", + "Web", + broken, + typeInfo, + sceneName: "Live" + ) + ); + _ = await Assert.ThrowsExactlyAsync(() => + client.Filters.SetSourceFilterSettingsAsync("Cam", "Grade", broken, typeInfo) + ); + _ = await Assert.ThrowsExactlyAsync(() => + client.Filters.CreateSourceFilterAsync( + "Cam", + "Grade", + "color_filter_v2", + broken, + typeInfo + ) + ); + + Assert.IsEmpty(server.Requests); + } + + [TestMethod] + [Timeout(TestTimeout)] + public async Task TransitionSettingsAsync_UnregisteredType_Throws() + { + await using FakeObsClient fake = await FakeObsClient.ConnectAsync(new FakeObsServer()); + + _ = await Assert.ThrowsExactlyAsync(() => + fake.Client.Transitions.GetCurrentSceneTransitionSettingsAsync() + ); + _ = await Assert.ThrowsExactlyAsync(() => + fake.Client.Transitions.SetCurrentSceneTransitionSettingsAsync( + new SwipeTransitionSettings("left") + ) + ); + } + + [TestMethod] + [Timeout(TestTimeout)] + public async Task GetCurrentSceneTransitionSettingsAsync_NoSettings_ReturnsNull() + { + FakeObsServer server = new(); + _ = server.Returns( + "GetCurrentSceneTransition", + """{"transitionName":"Cut","transitionUuid":"t","transitionKind":"cut_transition","transitionFixed":true,"transitionConfigurable":false,"transitionSettings":null}""" + ); + await using FakeObsClient fake = await FakeObsClient.ConnectAsync(server); + + Assert.IsNull( + await fake.Client.Transitions.GetCurrentSceneTransitionSettingsAsync( + HelperSettingsContext.Default.SwipeTransitionSettings + ) + ); + } + + #endregion + + #region Sources + + [TestMethod] + [Timeout(TestTimeout)] + public async Task SourceExistsAsync_ObsRefuses_ReturnsFalse() + { + FakeObsServer server = new(); + _ = server.Fails("GetInputList", (int)RequestStatusCode.InvalidResourceState); + await using FakeObsClient fake = await FakeObsClient.ConnectAsync(server); + + Assert.IsFalse(await fake.Client.Sources.SourceExistsAsync("Mic")); + } + + [TestMethod] + [Timeout(TestTimeout)] + public async Task SourceExistsAsync_TimesOut_Throws() + { + FakeObsServer server = new(); + _ = server.OnRequest("GetInputList", _ => FakeObsServer.RequestOutcome.NoReply); + await using FakeObsClient fake = await FakeObsClient.ConnectAsync( + server, + options => options.RequestTimeoutMs = 200 + ); + + // Absent would invite the caller to create it again. + _ = await Assert.ThrowsExactlyAsync(() => + fake.Client.Sources.SourceExistsAsync("Mic") + ); + } + + [TestMethod] + [Timeout(TestTimeout)] + [DataRow("""{"imageData":""}""")] + [DataRow("""{"imageData":"data:image/png;base64,***not base64***"}""")] + public async Task GetSourceScreenshotBytesAsync_NoUsableImage_ReturnsNull(string response) + { + FakeObsServer server = new(); + _ = server.Returns("GetSourceScreenshot", response); + await using FakeObsClient fake = await FakeObsClient.ConnectAsync(server); + + Assert.IsNull(await fake.Client.Sources.GetSourceScreenshotBytesAsync("Cam")); + } + + [TestMethod] + [Timeout(TestTimeout)] + public async Task GetSourceScreenshotOnCanvasBytesAsync_NoImage_ReturnsEmpty() + { + FakeObsServer server = new(); + _ = server.Returns("GetSourceScreenshot", """{"imageData":""}"""); + await using FakeObsClient fake = await FakeObsClient.ConnectAsync(server); + + Assert.IsEmpty(await fake.Client.Sources.GetSourceScreenshotOnCanvasBytesAsync("Cam")); + } + + [TestMethod] + [Timeout(TestTimeout)] + public async Task GetSourceScreenshotOnCanvasBytesAsync_NoDataUriPrefix_Decodes() + { + byte[] image = [9, 8, 7]; + FakeObsServer server = new(); + _ = server.Returns( + "GetSourceScreenshot", + $$"""{"imageData":"{{Convert.ToBase64String(image)}}"}""" + ); + await using FakeObsClient fake = await FakeObsClient.ConnectAsync(server); + + CollectionAssert.AreEqual( + image, + await fake.Client.Sources.GetSourceScreenshotOnCanvasBytesAsync("Cam") + ); + } + + #endregion + + #region Scenes + + [TestMethod] + [Timeout(TestTimeout)] + public async Task SwitchProgramSceneAndWaitAsync_ObsRefuses_Throws() + { + FakeObsServer server = new(); + _ = server.Returns( + "GetSceneList", + """{"currentProgramSceneName":"Live","currentProgramSceneUuid":"p","scenes":[]}""" + ); + _ = server.Fails("SetCurrentProgramScene", (int)RequestStatusCode.ResourceNotFound); + await using FakeObsClient fake = await FakeObsClient.ConnectAsync(server); + + ObsWebSocketRequestException error = + await Assert.ThrowsExactlyAsync(() => + fake.Client.Scenes.SwitchProgramSceneAndWaitAsync("Absent") + ); + Assert.AreEqual(RequestStatusCode.ResourceNotFound, error.StatusCode); + } + + [TestMethod] + [Timeout(TestTimeout)] + public async Task SwitchProgramSceneAndWaitAsync_Cancelled_ThrowsOperationCanceled() + { + FakeObsServer server = new(); + _ = server.Returns( + "GetSceneList", + """{"currentProgramSceneName":"Live","currentProgramSceneUuid":"p","scenes":[]}""" + ); + await using FakeObsClient fake = await FakeObsClient.ConnectAsync(server); + using CancellationTokenSource cancel = new(TimeSpan.FromMilliseconds(200)); + + _ = await Assert.ThrowsAsync(() => + fake.Client.Scenes.SwitchProgramSceneAndWaitAsync( + "Standby", + cancellationToken: cancel.Token + ) + ); + } + + [TestMethod] + [Timeout(TestTimeout)] + public async Task SwitchSceneAsync_ObsoleteForwarders_StillSwitch() + { + FakeObsServer server = new(); + _ = server.Returns( + "GetSceneList", + """{"currentProgramSceneName":"Standby","currentProgramSceneUuid":"s","scenes":[]}""" + ); + await using FakeObsClient fake = await FakeObsClient.ConnectAsync(server); + +#pragma warning disable CS0618 // The forwarders under test are the obsolete ones. + await fake.Client.Scenes.SwitchSceneAsync("Live"); + await fake.Client.Scenes.SwitchSceneAndWaitAsync("Standby"); +#pragma warning restore CS0618 + + CollectionAssert.Contains(server.Requests.ToList(), "SetCurrentProgramScene"); + } + + #endregion +} + +/// A value no serializer can write, standing in for a caller's broken type. +internal sealed class Unwritable; + +/// Refuses to read or write, as a faulty caller converter would. +internal sealed class RefusingConverter : JsonConverter +{ + public override Unwritable Read( + ref Utf8JsonReader reader, + Type typeToConvert, + JsonSerializerOptions options + ) => throw new JsonException("cannot read"); + + public override void Write( + Utf8JsonWriter writer, + Unwritable value, + JsonSerializerOptions options + ) => throw new JsonException("cannot write"); +} + +/// Settings whose only property cannot be serialized. +/// The property that refuses. +internal sealed record BrokenSettings( + [property: JsonConverter(typeof(RefusingConverter))] Unwritable Value +); + +[JsonSerializable(typeof(BrokenSettings))] +internal sealed partial class BrokenSettingsContext : JsonSerializerContext { } diff --git a/ObsWebSocket.Tests/GroupHelperTests.cs b/ObsWebSocket.Tests/GroupHelperTests.cs new file mode 100644 index 0000000..2a608de --- /dev/null +++ b/ObsWebSocket.Tests/GroupHelperTests.cs @@ -0,0 +1,779 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using ObsWebSocket.Core; +using ObsWebSocket.Core.Protocol.Common.FilterSettings; +using ObsWebSocket.Core.Protocol.Common.InputSettings; +using ObsWebSocket.Core.Protocol.Common.StreamServiceSettings; +using ObsWebSocket.Core.Protocol.Generated; +using ObsWebSocket.Core.Protocol.Responses; +using ObsWebSocket.Tests.Fakes; + +namespace ObsWebSocket.Tests; + +/// +/// The hand written group helpers, driven over the in-memory OBS. These wrap one or more requests +/// and sometimes wait for an event, which is the part that silently stops working. +/// +[TestClass] +public sealed class GroupHelperTests +{ + private const int TestTimeout = 30_000; + + private const string SceneList = + """{"currentProgramSceneName":"Live","currentProgramSceneUuid":"p","currentPreviewSceneName":"Next","currentPreviewSceneUuid":"n","scenes":[{"sceneName":"Live","sceneUuid":"p","sceneIndex":0},{"sceneName":"Standby","sceneUuid":"s","sceneIndex":1}]}"""; + + private static FakeObsServer ServerWithScenes() + { + FakeObsServer server = new(); + _ = server.Returns("GetSceneList", SceneList); + return server; + } + + #region Scenes + + [TestMethod] + [Timeout(TestTimeout)] + public async Task SceneExistsAsync_Name_AnswersFromSceneList() + { + await using FakeObsClient fake = await FakeObsClient.ConnectAsync(ServerWithScenes()); + + Assert.IsTrue(await fake.Client.Scenes.SceneExistsAsync("Standby")); + Assert.IsFalse(await fake.Client.Scenes.SceneExistsAsync("Absent")); + _ = await Assert.ThrowsExactlyAsync(() => + fake.Client.Scenes.SceneExistsAsync("") + ); + } + + [TestMethod] + [Timeout(TestTimeout)] + public async Task SwitchProgramSceneAsync_WithTransition_SetsTransitionFirst() + { + FakeObsServer server = ServerWithScenes(); + await using FakeObsClient fake = await FakeObsClient.ConnectAsync(server); + + await fake.Client.Scenes.SwitchProgramSceneAsync("Standby", "Fade", 300); + + CollectionAssert.AreEqual( + new[] + { + "SetCurrentSceneTransition", + "SetCurrentSceneTransitionDuration", + "SetCurrentProgramScene", + }, + server.Requests.ToList() + ); + } + + [TestMethod] + [Timeout(TestTimeout)] + public async Task SwitchPreviewSceneAsync_Scene_SendsPreviewRequest() + { + FakeObsServer server = ServerWithScenes(); + await using FakeObsClient fake = await FakeObsClient.ConnectAsync(server); + + await fake.Client.Scenes.SwitchPreviewSceneAsync("Standby"); + + CollectionAssert.Contains(server.Requests.ToList(), "SetCurrentPreviewScene"); + } + + [TestMethod] + [Timeout(TestTimeout)] + public async Task SwitchProgramSceneAndWaitAsync_Confirmed_Returns() + { + FakeObsServer server = ServerWithScenes(); + _ = server.OnRequest( + "SetCurrentProgramScene", + _ => + { + server.RaiseEvent( + "CurrentProgramSceneChanged", + """{"sceneName":"Standby","sceneUuid":"s"}""" + ); + return FakeObsServer.RequestOutcome.Success(); + } + ); + + await using FakeObsClient fake = await FakeObsClient.ConnectAsync(server); + + await fake.Client.Scenes.SwitchProgramSceneAndWaitAsync("Standby"); + } + + [TestMethod] + [Timeout(TestTimeout)] + public async Task SwitchPreviewSceneAndWaitAsync_Confirmed_Returns() + { + FakeObsServer server = ServerWithScenes(); + _ = server.OnRequest( + "SetCurrentPreviewScene", + _ => + { + server.RaiseEvent( + "CurrentPreviewSceneChanged", + """{"sceneName":"Standby","sceneUuid":"s"}""" + ); + return FakeObsServer.RequestOutcome.Success(); + } + ); + + await using FakeObsClient fake = await FakeObsClient.ConnectAsync(server); + + await fake.Client.Scenes.SwitchPreviewSceneAndWaitAsync("Standby"); + } + + [TestMethod] + [Timeout(TestTimeout)] + public async Task SwitchProgramSceneAndWaitAsync_AlreadyActive_SendsNothing() + { + FakeObsServer server = ServerWithScenes(); + await using FakeObsClient fake = await FakeObsClient.ConnectAsync(server); + + // OBS raises no event for a switch that changes nothing, so the wait would hang. + await fake.Client.Scenes.SwitchProgramSceneAndWaitAsync("Live"); + + Assert.DoesNotContain("SetCurrentProgramScene", server.Requests.ToList()); + } + + [TestMethod] + [Timeout(TestTimeout)] + public async Task SwitchProgramSceneAndWaitAsync_NoConfirmation_ThrowsTimeout() + { + FakeObsServer server = ServerWithScenes(); + await using FakeObsClient fake = await FakeObsClient.ConnectAsync(server); + + _ = await Assert.ThrowsExactlyAsync(() => + fake.Client.Scenes.SwitchProgramSceneAndWaitAsync( + "Standby", + TimeSpan.FromMilliseconds(200) + ) + ); + } + + #endregion + + #region Outputs, record and stream + + [TestMethod] + [Timeout(TestTimeout)] + public async Task SetVirtualCamActiveAndWaitAsync_Confirmed_ReturnsEventState() + { + FakeObsServer server = new(); + _ = server.OnRequest( + "StartVirtualCam", + _ => + { + server.RaiseEvent("VirtualcamStateChanged", """{"outputActive":true}"""); + return FakeObsServer.RequestOutcome.Success(); + } + ); + + await using FakeObsClient fake = await FakeObsClient.ConnectAsync(server); + + Assert.IsTrue(await fake.Client.Outputs.SetVirtualCamActiveAndWaitAsync(activate: true)); + } + + [TestMethod] + [Timeout(TestTimeout)] + public async Task SetVirtualCamActiveAndWaitAsync_NoConfirmation_ReturnsNull() + { + FakeObsServer server = new(); + await using FakeObsClient fake = await FakeObsClient.ConnectAsync(server); + + Assert.IsNull( + await fake.Client.Outputs.SetVirtualCamActiveAndWaitAsync( + activate: false, + TimeSpan.FromMilliseconds(200) + ) + ); + CollectionAssert.Contains(server.Requests.ToList(), "StopVirtualCam"); + } + + [TestMethod] + [Timeout(TestTimeout)] + public async Task IsVirtualCamActiveAsync_Active_ReturnsTrue() + { + FakeObsServer server = new(); + _ = server.Returns("GetVirtualCamStatus", """{"outputActive":true}"""); + + await using FakeObsClient fake = await FakeObsClient.ConnectAsync(server); + + Assert.IsTrue(await fake.Client.Outputs.IsVirtualCamActiveAsync()); + } + + [TestMethod] + [Timeout(TestTimeout)] + public async Task SetRecordActiveAndWaitAsync_Confirmed_ReturnsEventState() + { + FakeObsServer server = new(); + _ = server.OnRequest( + "StartRecord", + _ => + { + server.RaiseEvent( + "RecordStateChanged", + """{"outputActive":true,"outputState":"OBS_WEBSOCKET_OUTPUT_STARTED"}""" + ); + return FakeObsServer.RequestOutcome.Success(); + } + ); + _ = server.Returns( + "GetRecordStatus", + """{"outputActive":true,"outputPaused":false,"outputTimecode":"00:00:01.000","outputDuration":1000,"outputBytes":1}""" + ); + + await using FakeObsClient fake = await FakeObsClient.ConnectAsync(server); + + Assert.AreEqual( + OutputState.Started, + await fake.Client.Record.SetRecordActiveAndWaitAsync(activate: true) + ); + Assert.IsTrue(await fake.Client.Record.IsRecordActiveAsync()); + } + + [TestMethod] + [Timeout(TestTimeout)] + public async Task SetRecordActiveAndWaitAsync_NoConfirmation_ReturnsNull() + { + FakeObsServer server = new(); + _ = server.Returns("StopRecord", """{"outputPath":"C:/clips/one.mkv"}"""); + + await using FakeObsClient fake = await FakeObsClient.ConnectAsync(server); + + Assert.IsNull( + await fake.Client.Record.SetRecordActiveAndWaitAsync( + activate: false, + TimeSpan.FromMilliseconds(200) + ) + ); + } + + [TestMethod] + [Timeout(TestTimeout)] + public async Task SetStreamActiveAndWaitAsync_Confirmed_ReturnsEventState() + { + FakeObsServer server = new(); + _ = server.OnRequest( + "StartStream", + _ => + { + server.RaiseEvent( + "StreamStateChanged", + """{"outputActive":true,"outputState":"OBS_WEBSOCKET_OUTPUT_STARTED"}""" + ); + return FakeObsServer.RequestOutcome.Success(); + } + ); + _ = server.Returns( + "GetStreamStatus", + """{"outputActive":true,"outputReconnecting":false,"outputTimecode":"00:00:01.000","outputDuration":1000,"outputCongestion":0,"outputBytes":1,"outputSkippedFrames":0,"outputTotalFrames":1}""" + ); + + await using FakeObsClient fake = await FakeObsClient.ConnectAsync(server); + + Assert.AreEqual( + OutputState.Started, + await fake.Client.Stream.SetStreamActiveAndWaitAsync(activate: true) + ); + Assert.IsTrue(await fake.Client.Stream.IsStreamActiveAsync()); + } + + [TestMethod] + [Timeout(TestTimeout)] + public async Task SetStreamActiveAndWaitAsync_NoConfirmation_ReturnsNull() + { + FakeObsServer server = new(); + await using FakeObsClient fake = await FakeObsClient.ConnectAsync(server); + + Assert.IsNull( + await fake.Client.Stream.SetStreamActiveAndWaitAsync( + activate: false, + TimeSpan.FromMilliseconds(200) + ) + ); + CollectionAssert.Contains(server.Requests.ToList(), "StopStream"); + } + + [TestMethod] + [Timeout(TestTimeout)] + public async Task OutputSettingsAsync_TypedHelpers_RoundTrip() + { + FakeObsServer server = new(); + _ = server.Returns( + "GetOutputSettings", + """{"outputSettings":{"path":"C:/clips","format_name":"mkv"}}""" + ); + JsonElement? sent = null; + _ = server.OnRequest( + "SetOutputSettings", + data => + { + sent = data?.GetProperty("outputSettings"); + return FakeObsServer.RequestOutcome.Success(); + } + ); + + await using FakeObsClient fake = await FakeObsClient.ConnectAsync(server); + + RecordOutputSettings? read = await fake.Client.Outputs.GetOutputSettingsAsync( + "adv_file_output", + HelperSettingsContext.Default.RecordOutputSettings + ); + Assert.AreEqual("C:/clips", read?.Path); + + await fake.Client.Outputs.SetOutputSettingsAsync( + "adv_file_output", + new RecordOutputSettings("D:/clips"), + HelperSettingsContext.Default.RecordOutputSettings + ); + Assert.AreEqual("D:/clips", sent?.GetProperty("path").GetString()); + } + + #endregion + + #region Inputs and filters + + [TestMethod] + [Timeout(TestTimeout)] + public async Task SetInputTextAsync_Text_SendsTextSettings() + { + FakeObsServer server = new(); + JsonElement? sent = null; + _ = server.OnRequest( + "SetInputSettings", + data => + { + sent = data?.GetProperty("inputSettings"); + return FakeObsServer.RequestOutcome.Success(); + } + ); + + await using FakeObsClient fake = await FakeObsClient.ConnectAsync(server); + + await fake.Client.Inputs.SetInputTextAsync("Caption", "on air"); + + Assert.AreEqual("on air", sent?.GetProperty("text").GetString()); + } + + [TestMethod] + [Timeout(TestTimeout)] + public async Task SetInputMutesAsync_Several_ReturnsOneResultEach() + { + FakeObsServer server = new(); + int call = 0; + _ = server.OnRequest( + "SetInputMute", + _ => + ++call == 2 + ? FakeObsServer.RequestOutcome.Failure((int)RequestStatusCode.ResourceNotFound) + : FakeObsServer.RequestOutcome.Success() + ); + + await using FakeObsClient fake = await FakeObsClient.ConnectAsync(server); + + BatchResults results = await fake.Client.Inputs.SetInputMutesAsync([ + ("Mic", true), + ("Desktop", false), + ("Music", true), + ]); + + Assert.HasCount(3, results); + Assert.IsTrue(results[0].RequestStatus.Result); + Assert.IsFalse(results[1].RequestStatus.Result); + Assert.IsTrue(results[2].RequestStatus.Result); + } + + [TestMethod] + [Timeout(TestTimeout)] + public async Task SetInputMutesAsync_Empty_SendsNothing() + { + FakeObsServer server = new(); + await using FakeObsClient fake = await FakeObsClient.ConnectAsync(server); + + BatchResults results = await fake.Client.Inputs.SetInputMutesAsync([]); + + Assert.IsEmpty(results); + Assert.IsEmpty(server.Requests); + } + + [TestMethod] + [Timeout(TestTimeout)] + public async Task InputSettingsAsync_TypedHelpers_RoundTrip() + { + FakeObsServer server = new(); + _ = server.Returns( + "GetInputSettings", + """{"inputSettings":{"url":"https://example.com","width":1920},"inputKind":"browser_source"}""" + ); + _ = server.Returns( + "GetInputDefaultSettings", + """{"defaultInputSettings":{"url":"https://obsproject.com","width":800}}""" + ); + JsonElement? sent = null; + _ = server.OnRequest( + "SetInputSettings", + data => + { + sent = data?.GetProperty("inputSettings"); + return FakeObsServer.RequestOutcome.Success(); + } + ); + _ = server.Returns( + "CreateInput", + """{"inputUuid":"00000000-0000-0000-0000-0000000000ff","sceneItemId":3}""" + ); + + await using FakeObsClient fake = await FakeObsClient.ConnectAsync(server); + + BrowserSourceSettings? read = + await fake.Client.Inputs.GetInputSettingsAsync("Web"); + Assert.AreEqual("https://example.com", read?.Url); + + BrowserSourceSettings? defaults = + await fake.Client.Inputs.GetInputDefaultSettingsAsync( + "browser_source" + ); + Assert.AreEqual("https://obsproject.com", defaults?.Url); + + await fake.Client.Inputs.SetInputSettingsAsync( + "Web", + new BrowserSourceSettings(Url: "https://example.net") + ); + Assert.AreEqual("https://example.net", sent?.GetProperty("url").GetString()); + + CreateInputResponseData? created = await fake.Client.Inputs.CreateInputAsync( + "browser_source", + "Web 2", + new BrowserSourceSettings(Url: "https://example.org"), + sceneName: "Live" + ); + Assert.AreEqual(3, created?.SceneItemId); + } + + [TestMethod] + [Timeout(TestTimeout)] + public async Task FilterSettingsAsync_TypedHelpers_RoundTrip() + { + FakeObsServer server = new(); + _ = server.Returns( + "GetSourceFilterDefaultSettings", + """{"defaultFilterSettings":{"opacity":1.0}}""" + ); + _ = server.Returns( + "GetSourceFilter", + """{"filterEnabled":true,"filterIndex":0,"filterKind":"color_filter_v2","filterSettings":{"opacity":0.5}}""" + ); + JsonElement? sentSettings = null; + _ = server.OnRequest( + "SetSourceFilterSettings", + data => + { + sentSettings = data?.GetProperty("filterSettings"); + return FakeObsServer.RequestOutcome.Success(); + } + ); + JsonElement? createdSettings = null; + _ = server.OnRequest( + "CreateSourceFilter", + data => + { + createdSettings = data?.GetProperty("filterSettings"); + return FakeObsServer.RequestOutcome.Success(); + } + ); + + await using FakeObsClient fake = await FakeObsClient.ConnectAsync(server); + + ColorCorrectionFilterSettings? read = + await fake.Client.Filters.GetSourceFilterSettingsAsync( + "Cam", + "Grade" + ); + Assert.AreEqual(0.5, read?.Opacity); + + ColorCorrectionFilterSettings? defaults = + await fake.Client.Filters.GetSourceFilterDefaultSettingsAsync( + "color_filter_v2" + ); + Assert.AreEqual(1.0, defaults?.Opacity); + + await fake.Client.Filters.SetSourceFilterSettingsAsync( + "Cam", + "Grade", + new ColorCorrectionFilterSettings(Opacity: 0.25) + ); + Assert.AreEqual(0.25, sentSettings?.GetProperty("opacity").GetDouble()); + + await fake.Client.Filters.CreateSourceFilterAsync( + "Cam", + "Grade 2", + "color_filter_v2", + new ColorCorrectionFilterSettings(Opacity: 0.75) + ); + Assert.AreEqual(0.75, createdSettings?.GetProperty("opacity").GetDouble()); + } + + #endregion + + #region Config, transitions, general and sources + + [TestMethod] + [Timeout(TestTimeout)] + public async Task EnsureActiveAsync_AlreadyActive_SwitchesOnlyWhenNeeded() + { + FakeObsServer server = new(); + _ = server.Returns( + "GetSceneCollectionList", + """{"currentSceneCollectionName":"Show","sceneCollections":["Show","Other"]}""" + ); + _ = server.Returns( + "GetProfileList", + """{"currentProfileName":"Stream","profiles":["Stream","Local"]}""" + ); + + await using FakeObsClient fake = await FakeObsClient.ConnectAsync(server); + + Assert.IsTrue(await fake.Client.Config.EnsureSceneCollectionActiveAsync("Show")); + Assert.IsTrue(await fake.Client.Config.EnsureProfileActiveAsync("Stream")); + Assert.DoesNotContain("SetCurrentSceneCollection", server.Requests.ToList()); + Assert.DoesNotContain("SetCurrentProfile", server.Requests.ToList()); + + Assert.IsTrue(await fake.Client.Config.EnsureSceneCollectionActiveAsync("Other")); + Assert.IsTrue(await fake.Client.Config.EnsureProfileActiveAsync("Local")); + CollectionAssert.Contains(server.Requests.ToList(), "SetCurrentSceneCollection"); + CollectionAssert.Contains(server.Requests.ToList(), "SetCurrentProfile"); + } + + [TestMethod] + [Timeout(TestTimeout)] + [DataRow((int)RequestStatusCode.ResourceNotFound)] + [DataRow((int)RequestStatusCode.InvalidRequestField)] + public async Task EnsureActiveAsync_UnknownName_ReturnsFalse(int code) + { + FakeObsServer server = new(); + _ = server.Returns( + "GetSceneCollectionList", + """{"currentSceneCollectionName":"Show","sceneCollections":["Show"]}""" + ); + _ = server.Returns( + "GetProfileList", + """{"currentProfileName":"Stream","profiles":["Stream"]}""" + ); + _ = server.Fails("SetCurrentSceneCollection", code); + _ = server.Fails("SetCurrentProfile", code); + + await using FakeObsClient fake = await FakeObsClient.ConnectAsync(server); + + Assert.IsFalse(await fake.Client.Config.EnsureSceneCollectionActiveAsync("Absent")); + Assert.IsFalse(await fake.Client.Config.EnsureProfileActiveAsync("Absent")); + } + + [TestMethod] + [Timeout(TestTimeout)] + public async Task StreamServiceSettingsAsync_TypedHelpers_RoundTrip() + { + FakeObsServer server = new(); + _ = server.Returns( + "GetStreamServiceSettings", + """{"streamServiceType":"rtmp_common","streamServiceSettings":{"server":"auto","key":"live_1"}}""" + ); + JsonElement? sent = null; + _ = server.OnRequest( + "SetStreamServiceSettings", + data => + { + sent = data?.GetProperty("streamServiceSettings"); + return FakeObsServer.RequestOutcome.Success(); + } + ); + + await using FakeObsClient fake = await FakeObsClient.ConnectAsync(server); + + RtmpCommonStreamServiceSettings? read = + await fake.Client.Config.GetStreamServiceSettingsAsync(); + Assert.AreEqual("live_1", read?.Key); + + await fake.Client.Config.SetStreamServiceSettingsAsync( + "rtmp_common", + new RtmpCommonStreamServiceSettings(Server: "auto", Key: "live_2") + ); + Assert.AreEqual("live_2", sent?.GetProperty("key").GetString()); + } + + [TestMethod] + [Timeout(TestTimeout)] + public async Task TransitionSettingsAsync_TypedHelpers_RoundTrip() + { + FakeObsServer server = new(); + _ = server.Returns( + "GetCurrentSceneTransition", + """{"transitionName":"Swipe","transitionUuid":"t","transitionKind":"swipe_transition","transitionFixed":false,"transitionConfigurable":true,"transitionSettings":{"direction":"left"}}""" + ); + JsonElement? sent = null; + _ = server.OnRequest( + "SetCurrentSceneTransitionSettings", + data => + { + sent = data?.GetProperty("transitionSettings"); + return FakeObsServer.RequestOutcome.Success(); + } + ); + + await using FakeObsClient fake = await FakeObsClient.ConnectAsync(server); + + SwipeTransitionSettings? read = + await fake.Client.Transitions.GetCurrentSceneTransitionSettingsAsync( + HelperSettingsContext.Default.SwipeTransitionSettings + ); + Assert.AreEqual("left", read?.Direction); + + await fake.Client.Transitions.SetCurrentSceneTransitionSettingsAsync( + new SwipeTransitionSettings("right"), + HelperSettingsContext.Default.SwipeTransitionSettings + ); + Assert.AreEqual("right", sent?.GetProperty("direction").GetString()); + } + + [TestMethod] + [Timeout(TestTimeout)] + public async Task TriggerHotkeyAsync_Name_SendsCanonicalName() + { + FakeObsServer server = new(); + string? name = null; + _ = server.OnRequest( + "TriggerHotkeyByName", + data => + { + name = data?.GetProperty("hotkeyName").GetString(); + return FakeObsServer.RequestOutcome.Success(); + } + ); + + await using FakeObsClient fake = await FakeObsClient.ConnectAsync(server); + + await fake.Client.General.TriggerHotkeyAsync("OBSBasic.StartStreaming"); + + Assert.AreEqual("OBSBasic.StartStreaming", name); + _ = await Assert.ThrowsExactlyAsync(() => + fake.Client.General.TriggerHotkeyAsync("") + ); + } + + [TestMethod] + [Timeout(TestTimeout)] + public async Task SourceExistsAsync_Name_ChecksInputsThenScenes() + { + FakeObsServer server = ServerWithScenes(); + _ = server.Returns( + "GetInputList", + """{"inputs":[{"inputName":"Mic","inputUuid":"i","inputKind":"wasapi_input_capture","unversionedInputKind":"wasapi_input_capture"}]}""" + ); + + await using FakeObsClient fake = await FakeObsClient.ConnectAsync(server); + + Assert.IsTrue(await fake.Client.Sources.SourceExistsAsync("Mic"), "an input counts"); + Assert.IsTrue(await fake.Client.Sources.SourceExistsAsync("Live"), "a scene counts too"); + Assert.IsFalse(await fake.Client.Sources.SourceExistsAsync("Absent")); + } + + [TestMethod] + [Timeout(TestTimeout)] + public async Task GetSourceScreenshotBytesAsync_DataUri_ReturnsDecodedBytes() + { + byte[] image = [1, 2, 3, 4]; + string encoded = "data:image/png;base64," + Convert.ToBase64String(image); + FakeObsServer server = new(); + _ = server.Returns("GetSourceScreenshot", $$"""{"imageData":"{{encoded}}"}"""); + + await using FakeObsClient fake = await FakeObsClient.ConnectAsync(server); + + CollectionAssert.AreEqual( + image, + await fake.Client.Sources.GetSourceScreenshotBytesAsync("Cam") + ); + CollectionAssert.AreEqual( + image, + await fake.Client.Sources.GetSourceScreenshotOnCanvasBytesAsync("Cam") + ); + } + + [TestMethod] + [Timeout(TestTimeout)] + public async Task GetSourceScreenshotBytesAsync_UnknownSource_ReturnsNull() + { + FakeObsServer server = new(); + _ = server.Fails("GetSourceScreenshot", (int)RequestStatusCode.ResourceNotFound); + + await using FakeObsClient fake = await FakeObsClient.ConnectAsync(server); + + Assert.IsNull(await fake.Client.Sources.GetSourceScreenshotBytesAsync("Absent")); + } + + [TestMethod] + [Timeout(TestTimeout)] + public async Task SaveSourceScreenshotToFileAsync_Path_SendsPath() + { + string path = Path.Combine(Path.GetTempPath(), $"obsws-{Guid.NewGuid():N}.png"); + FakeObsServer server = new(); + string? requestedPath = null; + _ = server.OnRequest( + "SaveSourceScreenshot", + data => + { + requestedPath = data?.GetProperty("imageFilePath").GetString(); + return FakeObsServer.RequestOutcome.Success(); + } + ); + + await using FakeObsClient fake = await FakeObsClient.ConnectAsync(server); + + await fake.Client.Sources.SaveSourceScreenshotToFileAsync("Cam", path); + + Assert.AreEqual(path, requestedPath); + } + + #endregion + + #region Not connected + + [TestMethod] + [Timeout(TestTimeout)] + public async Task Helpers_NotConnected_Throw() + { + await using FakeObsClient fake = FakeObsClient.Build(new FakeObsServer()); + ObsWebSocketClient client = fake.Client; + + await AssertRefusedAsync(() => client.Scenes.SceneExistsAsync("Live")); + await AssertRefusedAsync(() => client.Scenes.SwitchProgramSceneAsync("Live")); + await AssertRefusedAsync(() => client.Sources.SourceExistsAsync("Cam")); + await AssertRefusedAsync(() => client.Inputs.SetInputTextAsync("Caption", "x")); + await AssertRefusedAsync(() => client.Inputs.SetInputMutesAsync([("Mic", true)])); + await AssertRefusedAsync(() => + client.General.TriggerHotkeyAsync("OBSBasic.StartStreaming") + ); + await AssertRefusedAsync(() => client.Record.IsRecordActiveAsync()); + await AssertRefusedAsync(() => client.Stream.IsStreamActiveAsync()); + await AssertRefusedAsync(() => client.Outputs.IsVirtualCamActiveAsync()); + await AssertRefusedAsync(() => client.Config.EnsureProfileActiveAsync("Stream")); + await AssertRefusedAsync(() => client.Config.EnsureSceneCollectionActiveAsync("Show")); + } + + private static async Task AssertRefusedAsync(Func call) => + _ = await Assert.ThrowsExactlyAsync(call); + + #endregion +} + +/// An output settings shape a consumer would define, with no library registration. +/// Where the output writes. +internal sealed record RecordOutputSettings( + [property: JsonPropertyName("path")] string? Path = null +); + +/// A transition settings shape a consumer would define. +/// The direction the swipe runs in. +internal sealed record SwipeTransitionSettings( + [property: JsonPropertyName("direction")] string? Direction = null +); + +[JsonSerializable(typeof(RecordOutputSettings))] +[JsonSerializable(typeof(SwipeTransitionSettings))] +internal sealed partial class HelperSettingsContext : JsonSerializerContext { } diff --git a/ObsWebSocket.Tests/HandleOperationTests.cs b/ObsWebSocket.Tests/HandleOperationTests.cs index b1b2a93..0b9c15b 100644 --- a/ObsWebSocket.Tests/HandleOperationTests.cs +++ b/ObsWebSocket.Tests/HandleOperationTests.cs @@ -21,7 +21,7 @@ public sealed class HandleOperationTests private static readonly Guid s_uuid = new("5d5db648-93a5-4985-bff8-45f4c9fe15f7"); [TestMethod] - public async Task ANameHandleSendsTheNameAndNoUuid() + public async Task Operation_NameHandle_SendsNameOnly() { JsonElement sent = await CaptureAsync( (client, ct) => client.Scene("Intro").SetCurrentProgramAsync(ct) @@ -36,7 +36,7 @@ public async Task ANameHandleSendsTheNameAndNoUuid() } [TestMethod] - public async Task AUuidHandleSendsTheUuidAndNoName() + public async Task Operation_UuidHandle_SendsUuidOnly() { JsonElement sent = await CaptureAsync( (client, ct) => client.Scene(s_uuid).SetCurrentProgramAsync(ct) @@ -57,7 +57,7 @@ public async Task AUuidHandleSendsTheUuidAndNoName() /// The canvas travels with a name, because that is the only path OBS reads it on. /// [TestMethod] - public async Task ACanvasScopedNameSendsTheCanvas() + public async Task Operation_CanvasScopedName_SendsCanvas() { CanvasHandle vertical = CanvasHandle.FromUuid(s_uuid); @@ -76,7 +76,7 @@ public async Task ACanvasScopedNameSendsTheCanvas() /// A composite handle supplies both halves of the identity: the scene, and the id inside it. /// [TestMethod] - public async Task ASceneItemSendsItsSceneAndItsId() + public async Task Operation_SceneItem_SendsSceneAndId() { JsonElement sent = await CaptureAsync( (client, ct) => @@ -89,7 +89,7 @@ public async Task ASceneItemSendsItsSceneAndItsId() } [TestMethod] - public async Task AFilterSendsItsSourceAndItsName() + public async Task Operation_Filter_SendsSourceAndName() { JsonElement sent = await CaptureAsync( (client, ct) => @@ -106,7 +106,7 @@ public async Task AFilterSendsItsSourceAndItsName() /// subject. DuplicateSceneItem is the only request in the protocol shaped this way. /// [TestMethod] - public async Task ASecondReferenceIsAParameter() + public async Task Operation_SecondReference_IsParameter() { JsonElement sent = await CaptureAsync( (client, ct) => @@ -122,7 +122,7 @@ public async Task ASecondReferenceIsAParameter() /// A bare string is a name, which is the whole point of the implicit conversion. [TestMethod] - public async Task AStringReachesTheOperationsWithoutCeremony() + public async Task Operation_String_ConvertsImplicitly() { JsonElement sent = await CaptureAsync( (client, ct) => client.Input("Mic").ToggleMuteAsync(ct) diff --git a/ObsWebSocket.Tests/HandleResolutionTests.cs b/ObsWebSocket.Tests/HandleResolutionTests.cs new file mode 100644 index 0000000..1d648fa --- /dev/null +++ b/ObsWebSocket.Tests/HandleResolutionTests.cs @@ -0,0 +1,235 @@ +using ObsWebSocket.Core; +using ObsWebSocket.Core.Protocol.Generated; +using ObsWebSocket.Tests.Fakes; + +namespace ObsWebSocket.Tests; + +/// +/// Turning names into uuids. A wrong answer here addresses the wrong scene or source, so each +/// kind is checked for the found case, the missing case and its listing of what does exist. +/// +[TestClass] +public sealed class HandleResolutionTests +{ + private const int TestTimeout = 30_000; + + private const string SceneUuid = "11111111-1111-1111-1111-111111111111"; + private const string InputUuid = "22222222-2222-2222-2222-222222222222"; + private const string CanvasUuid = "33333333-3333-3333-3333-333333333333"; + + private static FakeObsServer Server() + { + FakeObsServer server = new(); + _ = server.Returns( + "GetSceneList", + $$"""{"currentProgramSceneName":"Live","currentProgramSceneUuid":"{{SceneUuid}}","scenes":[{"sceneName":"Live","sceneUuid":"{{SceneUuid}}","sceneIndex":0}]}""" + ); + _ = server.Returns( + "GetInputList", + $$"""{"inputs":[{"inputName":"Mic","inputUuid":"{{InputUuid}}","inputKind":"wasapi_input_capture","unversionedInputKind":"wasapi_input_capture"}]}""" + ); + _ = server.Returns( + "GetCanvasList", + $$$"""{"canvases":[{"canvasName":"Vertical","canvasUuid":"{{{CanvasUuid}}}","canvasFlags":{"MAIN":false,"ACTIVATE":true,"MIX_AUDIO":false,"SCENE_REF":false,"EPHEMERAL":false},"canvasVideoSettings":{"fpsNumerator":30,"fpsDenominator":1,"baseWidth":1080,"baseHeight":1920,"outputWidth":1080,"outputHeight":1920}}]}""" + ); + return server; + } + + [TestMethod] + [Timeout(TestTimeout)] + public async Task ResolveAsync_KnownName_ReturnsUuid() + { + await using FakeObsClient fake = await FakeObsClient.ConnectAsync(Server()); + ObsWebSocketClient client = fake.Client; + + Assert.AreEqual( + SceneUuid, + (await client.Scenes.ResolveAsync(SceneHandle.FromName("Live"))).Uuid + ); + Assert.AreEqual( + InputUuid, + (await client.Inputs.ResolveAsync(InputHandle.FromName("Mic"))).Uuid + ); + Assert.AreEqual( + CanvasUuid, + (await client.Canvases.ResolveAsync(CanvasHandle.FromName("Vertical"))).Uuid + ); + Assert.AreEqual( + InputUuid, + (await client.Sources.ResolveAsync(SourceHandle.FromName("Mic"))).Uuid + ); + } + + [TestMethod] + [Timeout(TestTimeout)] + public async Task ResolveAsync_SourceIsScene_ChecksInputsThenScenes() + { + FakeObsServer server = Server(); + await using FakeObsClient fake = await FakeObsClient.ConnectAsync(server); + + SourceHandle resolved = await fake.Client.Sources.ResolveAsync( + SourceHandle.FromName("Live") + ); + + Assert.AreEqual(SceneUuid, resolved.Uuid); + CollectionAssert.AreEqual( + new[] { "GetInputList", "GetSceneList" }, + server.Requests.ToList() + ); + } + + [TestMethod] + [Timeout(TestTimeout)] + public async Task ResolveAsync_AlreadyResolved_SendsNothing() + { + FakeObsServer server = Server(); + await using FakeObsClient fake = await FakeObsClient.ConnectAsync(server); + ObsWebSocketClient client = fake.Client; + + SceneHandle scene = SceneHandle.FromUuid(SceneUuid); + InputHandle input = InputHandle.FromUuid(InputUuid); + SourceHandle source = SourceHandle.FromUuid(InputUuid); + CanvasHandle canvas = CanvasHandle.FromUuid(CanvasUuid); + + Assert.AreSame(scene, await client.Scenes.ResolveAsync(scene)); + Assert.AreSame(input, await client.Inputs.ResolveAsync(input)); + Assert.AreSame(source, await client.Sources.ResolveAsync(source)); + Assert.AreSame(canvas, await client.Canvases.ResolveAsync(canvas)); + Assert.AreSame(CanvasHandle.Main, await client.Canvases.ResolveAsync(CanvasHandle.Main)); + + Assert.IsEmpty(server.Requests, "nothing needed looking up"); + } + + [TestMethod] + [Timeout(TestTimeout)] + public async Task ResolveAsync_UnknownName_ThrowsListingAvailable() + { + await using FakeObsClient fake = await FakeObsClient.ConnectAsync(Server()); + ObsWebSocketClient client = fake.Client; + + ObsWebSocketResourceNotFoundException scene = + await Assert.ThrowsExactlyAsync(async () => + await client.Scenes.ResolveAsync(SceneHandle.FromName("Absent")) + ); + Assert.AreEqual("scene", scene.Kind); + Assert.AreEqual("Absent", scene.RequestedName); + CollectionAssert.AreEqual(new[] { "Live" }, scene.Available.ToList()); + + ObsWebSocketResourceNotFoundException input = + await Assert.ThrowsExactlyAsync(async () => + await client.Inputs.ResolveAsync(InputHandle.FromName("Absent")) + ); + CollectionAssert.AreEqual(new[] { "Mic" }, input.Available.ToList()); + + ObsWebSocketResourceNotFoundException canvas = + await Assert.ThrowsExactlyAsync(async () => + await client.Canvases.ResolveAsync(CanvasHandle.FromName("Absent")) + ); + CollectionAssert.AreEqual(new[] { "Vertical" }, canvas.Available.ToList()); + + ObsWebSocketResourceNotFoundException source = + await Assert.ThrowsExactlyAsync(async () => + await client.Sources.ResolveAsync(SourceHandle.FromName("Absent")) + ); + CollectionAssert.AreEquivalent(new[] { "Mic", "Live" }, source.Available.ToList()); + } + + [TestMethod] + [Timeout(TestTimeout)] + public async Task ResolveAsync_KnownSceneItem_ReturnsId() + { + FakeObsServer server = Server(); + _ = server.Returns("GetSceneItemId", """{"sceneItemId":7}"""); + await using FakeObsClient fake = await FakeObsClient.ConnectAsync(server); + + SceneItemHandle item = await fake.Client.SceneItems.ResolveAsync( + SceneHandle.FromName("Live").Item("Mic") + ); + + Assert.AreEqual(7, item.SceneItemId); + } + + [TestMethod] + [Timeout(TestTimeout)] + public async Task ResolveAsync_UnknownSceneItem_ThrowsListingSceneSources() + { + FakeObsServer server = Server(); + _ = server.Fails("GetSceneItemId", (int)RequestStatusCode.ResourceNotFound); + _ = server.Returns( + "GetSceneItemList", + """{"sceneItems":[{"sourceName":"Mic","sceneItemId":1,"sceneItemIndex":0,"sourceType":"OBS_SOURCE_TYPE_INPUT","sourceUuid":"u","sceneItemEnabled":true,"sceneItemLocked":false,"sceneItemBlendMode":"OBS_BLEND_NORMAL","sceneItemTransform":{"positionX":0,"positionY":0,"rotation":0,"scaleX":1,"scaleY":1,"width":0,"height":0,"sourceWidth":0,"sourceHeight":0,"alignment":5,"boundsType":"OBS_BOUNDS_NONE","boundsAlignment":0,"boundsWidth":0,"boundsHeight":0,"cropLeft":0,"cropTop":0,"cropRight":0,"cropBottom":0},"isGroup":null,"inputKind":null}]}""" + ); + await using FakeObsClient fake = await FakeObsClient.ConnectAsync(server); + + ObsWebSocketResourceNotFoundException error = + await Assert.ThrowsExactlyAsync(async () => + await fake.Client.SceneItems.ResolveAsync( + SceneHandle.FromName("Live").Item("Camera") + ) + ); + + Assert.AreEqual("Camera", error.RequestedName); + CollectionAssert.AreEqual(new[] { "Mic" }, error.Available.ToList()); + Assert.IsInstanceOfType(error.InnerException); + } + + [TestMethod] + [Timeout(TestTimeout)] + public async Task ResolveAsync_UnknownSceneItemAndScene_ThrowsForItem() + { + FakeObsServer server = Server(); + _ = server.Fails("GetSceneItemId", (int)RequestStatusCode.ResourceNotFound); + _ = server.Fails("GetSceneItemList", (int)RequestStatusCode.ResourceNotFound); + await using FakeObsClient fake = await FakeObsClient.ConnectAsync(server); + + ObsWebSocketResourceNotFoundException error = + await Assert.ThrowsExactlyAsync(async () => + await fake.Client.SceneItems.ResolveAsync( + SceneHandle.FromName("Gone").Item("Camera") + ) + ); + + Assert.AreEqual("Camera", error.RequestedName); + Assert.IsEmpty(error.Available); + } + + [TestMethod] + [Timeout(TestTimeout)] + public async Task ResolveAsync_SceneItemOtherFailure_ThrowsRequestException() + { + FakeObsServer server = Server(); + _ = server.Fails("GetSceneItemId", (int)RequestStatusCode.InvalidResourceState); + await using FakeObsClient fake = await FakeObsClient.ConnectAsync(server); + + ObsWebSocketRequestException error = + await Assert.ThrowsExactlyAsync(async () => + await fake.Client.SceneItems.ResolveAsync(SceneHandle.FromName("Live").Item("Mic")) + ); + + Assert.AreEqual(RequestStatusCode.InvalidResourceState, error.StatusCode); + } + + [TestMethod] + [Timeout(TestTimeout)] + public async Task ResolveAsync_Null_Throws() + { + await using FakeObsClient fake = await FakeObsClient.ConnectAsync(Server()); + ObsWebSocketClient client = fake.Client; + + _ = await Assert.ThrowsExactlyAsync(async () => + await client.Scenes.ResolveAsync(null!) + ); + _ = await Assert.ThrowsExactlyAsync(async () => + await client.Inputs.ResolveAsync(null!) + ); + _ = await Assert.ThrowsExactlyAsync(async () => + await client.Canvases.ResolveAsync(null!) + ); + _ = await Assert.ThrowsExactlyAsync(async () => + await client.Sources.ResolveAsync(null!) + ); + _ = await Assert.ThrowsExactlyAsync(async () => + await client.SceneItems.ResolveAsync(null!) + ); + } +} diff --git a/ObsWebSocket.Tests/HandleTests.cs b/ObsWebSocket.Tests/HandleTests.cs index c946d18..d39c6ff 100644 --- a/ObsWebSocket.Tests/HandleTests.cs +++ b/ObsWebSocket.Tests/HandleTests.cs @@ -16,7 +16,7 @@ public sealed class HandleTests private static readonly Guid s_uuid = new("5d5db648-93a5-4985-bff8-45f4c9fe15f7"); [TestMethod] - public void AStringIsAName_AndAGuidIsAUuid() + public void Handle_StringOrGuid_BecomesNameOrUuid() { SceneHandle byName = "Intro"; SceneHandle byUuid = s_uuid; @@ -35,7 +35,7 @@ public void AStringIsAName_AndAGuidIsAUuid() /// through uuid_unparse_lower, which is what Guid's "D" format produces. /// [TestMethod] - public void AGuidIsFormattedTheWayObsWritesOne() + public void Handle_FromGuid_FormatsLikeObs() { Assert.AreEqual("5d5db648-93a5-4985-bff8-45f4c9fe15f7", SceneHandle.FromUuid(s_uuid).Uuid); Assert.AreEqual( @@ -49,7 +49,7 @@ public void AGuidIsFormattedTheWayObsWritesOne() /// break a response. /// [TestMethod] - public void AUuidFromTheWireIsCarriedVerbatim() + public void Handle_FromUuidString_KeepsItVerbatim() { Assert.AreEqual("not-a-guid", SceneHandle.FromUuid("not-a-guid").Uuid); } @@ -59,7 +59,7 @@ public void AUuidFromTheWireIsCarriedVerbatim() /// carrying a field the server ignores. /// [TestMethod] - public void ACanvasScopesANameAndIsDroppedByAUuid() + public void Handle_WithCanvas_ScopesNameButNotUuid() { CanvasHandle vertical = CanvasHandle.FromUuid(s_uuid); @@ -69,7 +69,7 @@ public void ACanvasScopesANameAndIsDroppedByAUuid() } [TestMethod] - public void TheMainCanvasCarriesNoUuid_WhichIsWhatOmittingTheFieldMeans() + public void CanvasHandle_Main_HasNoUuid() { Assert.IsNull(CanvasHandle.Main.Uuid); Assert.IsFalse(CanvasHandle.Main.IsResolved); @@ -80,7 +80,7 @@ public void TheMainCanvasCarriesNoUuid_WhichIsWhatOmittingTheFieldMeans() /// A scene is a source in OBS, and the requests that take a bare source accept either. /// [TestMethod] - public void ASceneAndAnInputBothNarrowToASource() + public void SourceHandle_FromSceneOrInput_Narrows() { Assert.AreEqual("Intro", SceneHandle.FromName("Intro").AsSource().Name); Assert.AreEqual( @@ -99,7 +99,7 @@ public void ASceneAndAnInputBothNarrowToASource() /// the missing lookup is a compile error rather than a runtime one. /// [TestMethod] - public void ASceneItemByIdIsAHandle_ButBySourceNameItIsNotYet() + public void SceneHandle_Item_IdIsHandleNameIsUnresolved() { SceneHandle intro = "Intro"; @@ -114,7 +114,7 @@ public void ASceneItemByIdIsAHandle_ButBySourceNameItIsNotYet() /// Filters have no uuid in the protocol, so the name is the identity. [TestMethod] - public void AFilterIsNamedOnItsSource() + public void FilterHandle_Created_NamedOnSource() { FilterHandle eq = InputHandle.FromName("Mic").Filter("EQ"); @@ -123,7 +123,7 @@ public void AFilterIsNamedOnItsSource() } [TestMethod] - public void AnEmptyNameIsRefusedRatherThanSentAsOne() + public void Handle_EmptyName_Throws() { _ = Assert.ThrowsExactly(() => SceneHandle.FromName(string.Empty)); _ = Assert.ThrowsExactly(() => InputHandle.FromName(string.Empty)); @@ -134,7 +134,7 @@ public void AnEmptyNameIsRefusedRatherThanSentAsOne() } [TestMethod] - public void ANegativeSceneItemIdIsRefused() + public void SceneItemHandle_NegativeId_Throws() { _ = Assert.ThrowsExactly(() => SceneHandle.FromName("Intro").Item(-1) @@ -146,7 +146,7 @@ public void ANegativeSceneItemIdIsRefused() /// by hand. /// [TestMethod] - public void TwoHandlesForTheSameThingAreEqual() + public void Handle_SameTarget_AreEqual() { Assert.AreEqual(SceneHandle.FromName("Intro"), (SceneHandle)"Intro"); Assert.AreEqual(SceneHandle.FromUuid(s_uuid), (SceneHandle)s_uuid); @@ -158,7 +158,7 @@ public void TwoHandlesForTheSameThingAreEqual() } [TestMethod] - public void AHandleSaysWhatItAddressesWhenPrinted() + public void Handle_ToString_DescribesTarget() { Assert.AreEqual("scene 'Intro'", SceneHandle.FromName("Intro").ToString()); Assert.AreEqual("the main canvas", CanvasHandle.Main.ToString()); @@ -177,7 +177,7 @@ public void AHandleSaysWhatItAddressesWhenPrinted() /// name again is the round trip and the race the uuid was there to avoid. /// [TestMethod] - public void AnEventCarriesAResolvedHandle() + public void EventPayload_WithUuid_ExposesResolvedHandle() { CurrentProgramSceneChangedPayload changed = new( sceneName: "Intro", @@ -193,7 +193,7 @@ public void AnEventCarriesAResolvedHandle() /// A scene item needs both halves, and an event that reports one carries both. /// [TestMethod] - public void AnEventCarriesAResolvedSceneItem() + public void EventPayload_WithSceneItem_ExposesResolvedItem() { SceneItemEnableStateChangedPayload changed = new( sceneName: "Intro", @@ -210,7 +210,7 @@ public void AnEventCarriesAResolvedSceneItem() /// Creating something answers with its uuid, so the handle for it costs no second request. /// [TestMethod] - public void CreatingSomethingAnswersWithAHandle() + public void CreateResponse_WithUuid_ExposesHandle() { CreateSceneResponseData created = new(sceneUuid: "5d5db648-93a5-4985-bff8-45f4c9fe15f7"); @@ -223,7 +223,7 @@ public void CreatingSomethingAnswersWithAHandle() /// than by marking the field optional. The accessor has to be nullable to match. /// [TestMethod] - public void AUuidTheProtocolSaysCanBeNullYieldsANullHandle() + public void Payload_NullableUuidMissing_ReturnsNullHandle() { GetSceneListResponseData outsideStudioMode = new( scenes: [], diff --git a/ObsWebSocket.Tests/HostedReconfigurationTests.cs b/ObsWebSocket.Tests/HostedReconfigurationTests.cs new file mode 100644 index 0000000..c1c2ab8 --- /dev/null +++ b/ObsWebSocket.Tests/HostedReconfigurationTests.cs @@ -0,0 +1,207 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using ObsWebSocket.Core; +using ObsWebSocket.Core.Networking; +using ObsWebSocket.Tests.Fakes; + +namespace ObsWebSocket.Tests; + +/// +/// The hosted connection following configuration: a change to what the socket is built from has +/// to reconnect, anything else must not, and shutdown has to leave nothing running. +/// +[TestClass] +public sealed class HostedReconfigurationTests +{ + private const int TestTimeout = 30_000; + + private sealed class Harness : IAsyncDisposable + { + public required ServiceProvider Provider { get; init; } + public required IConfigurationRoot Configuration { get; init; } + public required FakeObsServer Server { get; init; } + public required IHostedService Service { get; init; } + public required ObsWebSocketClient Client { get; init; } + + public void Change(string key, string value) + { + Configuration[$"Obs:{key}"] = value; + Configuration.Reload(); + } + + public async ValueTask DisposeAsync() => await Provider.DisposeAsync(); + } + + private static Harness Build(FakeObsServer server, string? name = null) + { + IConfigurationRoot configuration = new ConfigurationBuilder() + .AddInMemoryCollection( + new Dictionary + { + ["Obs:ServerUri"] = "ws://first:4455", + ["Obs:AutoReconnectEnabled"] = "false", + ["Obs:RequestTimeoutMs"] = "5000", + } + ) + .Build(); + + ServiceCollection services = new(); + _ = services.AddLogging(builder => builder.SetMinimumLevel(LogLevel.None)); + IObsWebSocketClientBuilder client = name is null + ? services.AddObsWebSocketClient() + : services.AddObsWebSocketClient(name); + _ = services.Configure( + name ?? Microsoft.Extensions.Options.Options.DefaultName, + configuration.GetSection("Obs") + ); + _ = client.WithAutoConnect(); + _ = services.AddSingleton(server); + + ServiceProvider provider = services.BuildServiceProvider(); + return new Harness + { + Provider = provider, + Configuration = configuration, + Server = server, + Service = provider.GetServices().Single(), + Client = name is null + ? provider.GetRequiredService() + : provider.GetRequiredKeyedService(name), + }; + } + + private static async Task WaitUntilAsync(Func condition) + { + using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(10)); + while (!condition()) + { + await Task.Delay(10, timeout.Token); + } + } + + [TestMethod] + [Timeout(TestTimeout)] + public async Task Service_StartThenStop_ConnectsThenDisconnects() + { + await using Harness harness = Build(new FakeObsServer()); + + await harness.Service.StartAsync(CancellationToken.None); + Assert.IsTrue(harness.Client.IsConnected); + + await harness.Service.StopAsync(CancellationToken.None); + Assert.IsFalse(harness.Client.IsConnected); + } + + [TestMethod] + [Timeout(TestTimeout)] + public async Task StartAsync_ObsUnreachable_DoesNotThrow() + { + await using Harness harness = Build(new FakeObsServer { RefuseConnections = true }); + + await harness.Service.StartAsync(CancellationToken.None); + + Assert.IsFalse(harness.Client.IsConnected); + await harness.Service.StopAsync(CancellationToken.None); + } + + [TestMethod] + [Timeout(TestTimeout)] + public async Task OptionsChange_NewEndpoint_Reconnects() + { + await using Harness harness = Build(new FakeObsServer()); + await harness.Service.StartAsync(CancellationToken.None); + + harness.Change("ServerUri", "ws://second:4455"); + + await WaitUntilAsync(() => + harness.Server.ConnectedTo.Count == 2 && harness.Client.IsConnected + ); + Assert.AreEqual(new Uri("ws://second:4455"), harness.Server.ConnectedTo[^1]); + + await harness.Service.StopAsync(CancellationToken.None); + } + + [TestMethod] + [Timeout(TestTimeout)] + public async Task OptionsChange_PerCallValue_KeepsConnection() + { + await using Harness harness = Build(new FakeObsServer()); + await harness.Service.StartAsync(CancellationToken.None); + + harness.Change("RequestTimeoutMs", "9000"); + + // Nothing to wait on for an absence, so give a reconnect time it would have needed. + await Task.Delay(200); + Assert.HasCount(1, harness.Server.ConnectedTo); + Assert.IsTrue(harness.Client.IsConnected); + + await harness.Service.StopAsync(CancellationToken.None); + } + + [TestMethod] + [Timeout(TestTimeout)] + public async Task OptionsChange_NamedClient_Reconnects() + { + await using Harness harness = Build(new FakeObsServer(), name: "studio"); + await harness.Service.StartAsync(CancellationToken.None); + + harness.Change("ServerUri", "ws://second:4455"); + + await WaitUntilAsync(() => + harness.Server.ConnectedTo.Count == 2 && harness.Client.IsConnected + ); + Assert.AreEqual(new Uri("ws://second:4455"), harness.Server.ConnectedTo[^1]); + + await harness.Service.StopAsync(CancellationToken.None); + } + + [TestMethod] + [Timeout(TestTimeout)] + public async Task OptionsChange_ReconnectFails_LaterChangeStillApplies() + { + FakeObsServer server = new(); + await using Harness harness = Build(server); + await harness.Service.StartAsync(CancellationToken.None); + + server.RefuseConnections = true; + harness.Change("ServerUri", "ws://second:4455"); + + await WaitUntilAsync(() => !harness.Client.IsConnected); + + // A later change still gets through once OBS is back. + server.RefuseConnections = false; + harness.Change("ServerUri", "ws://third:4455"); + await WaitUntilAsync(() => harness.Client.IsConnected); + Assert.AreEqual(new Uri("ws://third:4455"), server.ConnectedTo[^1]); + + await harness.Service.StopAsync(CancellationToken.None); + } + + [TestMethod] + [Timeout(TestTimeout)] + public async Task StopAsync_NotStarted_DoesNothing() + { + await using Harness harness = Build(new FakeObsServer()); + + await harness.Service.StopAsync(CancellationToken.None); + + Assert.IsFalse(harness.Client.IsConnected); + } + + [TestMethod] + public async Task WithAutoConnect_ObsoleteForwarder_RegistersService() + { + ServiceCollection services = new(); + _ = services.AddLogging(); + _ = services.AddObsWebSocketClient(o => o.ServerUri = new Uri("ws://localhost:4455")); + +#pragma warning disable CS0618 // The forwarder under test is the obsolete one. + _ = services.WithAutoConnect(); +#pragma warning restore CS0618 + + await using ServiceProvider provider = services.BuildServiceProvider(); + Assert.ContainsSingle(provider.GetServices()); + } +} diff --git a/ObsWebSocket.Tests/Integration/GroupFacadeTests.cs b/ObsWebSocket.Tests/Integration/GroupFacadeTests.cs new file mode 100644 index 0000000..784a633 --- /dev/null +++ b/ObsWebSocket.Tests/Integration/GroupFacadeTests.cs @@ -0,0 +1,112 @@ +using ObsWebSocket.Core; +using ObsWebSocket.Core.Protocol.Common.StreamServiceSettings; +using ObsWebSocket.Core.Protocol.Responses; + +namespace ObsWebSocket.Tests.Integration; + +/// +/// Exercises the hand written group helpers against a live OBS. These are the surface most +/// callers use, and nothing else in the suite reaches them. +/// +[TestClass] +[DoNotParallelize] +[TestCategory("Integration")] +public sealed class GroupFacadeTests +{ + private const int TimeoutMs = 120_000; + + /// The context MSTest assigns, used for cancellation. + public TestContext TestContext { get; set; } = null!; + + [TestMethod] + [Timeout(TimeoutMs, CooperativeCancellation = true)] + public async Task SceneHelpers_LiveObs_ReportAndSwitch() + { + CancellationToken token = TestContext.CancellationToken; + await using LiveClient live = await LiveClient + .ConnectAsync(SerializationFormat.Json, TestContext) + .ConfigureAwait(false); + + GetSceneListResponseData scenes = await live + .Client.Scenes.GetSceneListAsync(new(), token) + .ConfigureAwait(false); + string current = scenes.CurrentProgramSceneName!; + + Assert.IsTrue( + await live.Client.Scenes.SceneExistsAsync(current, token).ConfigureAwait(false) + ); + Assert.IsFalse( + await live + .Client.Scenes.SceneExistsAsync("__obsws_absent__", token) + .ConfigureAwait(false) + ); + + await live + .Client.Scenes.SwitchProgramSceneAsync(current, cancellationToken: token) + .ConfigureAwait(false); + await live + .Client.Scenes.SwitchProgramSceneAndWaitAsync(current, cancellationToken: token) + .ConfigureAwait(false); + } + + [TestMethod] + [Timeout(TimeoutMs, CooperativeCancellation = true)] + public async Task ConfigHelpers_LiveObs_ReadAndEnsure() + { + CancellationToken token = TestContext.CancellationToken; + await using LiveClient live = await LiveClient + .ConnectAsync(SerializationFormat.Json, TestContext) + .ConfigureAwait(false); + + GetProfileListResponseData profiles = await live + .Client.Config.GetProfileListAsync(token) + .ConfigureAwait(false); + GetSceneCollectionListResponseData collections = await live + .Client.Config.GetSceneCollectionListAsync(token) + .ConfigureAwait(false); + + Assert.IsTrue( + await live + .Client.Config.EnsureProfileActiveAsync(profiles.CurrentProfileName!, token) + .ConfigureAwait(false) + ); + Assert.IsFalse( + await live + .Client.Config.EnsureProfileActiveAsync("__obsws_absent__", token) + .ConfigureAwait(false) + ); + Assert.IsTrue( + await live + .Client.Config.EnsureSceneCollectionActiveAsync( + collections.CurrentSceneCollectionName!, + token + ) + .ConfigureAwait(false) + ); + + // Typed read of the stream service settings, written back unchanged. + RtmpCommonStreamServiceSettings? settings = await live + .Client.Config.GetStreamServiceSettingsAsync(token) + .ConfigureAwait(false); + TestContext.WriteLine($"stream service settings: {settings is not null}"); + } + + [TestMethod] + [Timeout(TimeoutMs, CooperativeCancellation = true)] + public async Task OutputHelpers_LiveObs_Report() + { + CancellationToken token = TestContext.CancellationToken; + await using LiveClient live = await LiveClient + .ConnectAsync(SerializationFormat.Json, TestContext) + .ConfigureAwait(false); + + Assert.IsFalse(await live.Client.Record.IsRecordActiveAsync(token).ConfigureAwait(false)); + Assert.IsFalse(await live.Client.Stream.IsStreamActiveAsync(token).ConfigureAwait(false)); + Assert.IsFalse( + await live.Client.Outputs.IsVirtualCamActiveAsync(token).ConfigureAwait(false) + ); + + string outputName = live.Outputs.Outputs[0].OutputName; + TestContext.WriteLine($"first output: {outputName}"); + } +} diff --git a/ObsWebSocket.Tests/Integration/HandleTests.Live.cs b/ObsWebSocket.Tests/Integration/HandleTests.Live.cs new file mode 100644 index 0000000..dce303c --- /dev/null +++ b/ObsWebSocket.Tests/Integration/HandleTests.Live.cs @@ -0,0 +1,194 @@ +using ObsWebSocket.Core; +using ObsWebSocket.Core.Protocol.Requests; +using ObsWebSocket.Core.Protocol.Responses; + +namespace ObsWebSocket.Tests.Integration; + +/// +/// Resolves handles against a live OBS. A name resolves to a uuid, the uuid survives a rename, +/// and a miss reports what does exist. +/// +[TestClass] +[DoNotParallelize] +[TestCategory("Integration")] +public sealed class LiveHandleTests +{ + private const int TimeoutMs = 120_000; + + /// The context MSTest assigns, used for cancellation. + public TestContext TestContext { get; set; } = null!; + + [TestMethod] + [Timeout(TimeoutMs, CooperativeCancellation = true)] + public async Task SceneHandles_LiveObs_Resolve() + { + CancellationToken token = TestContext.CancellationToken; + await using LiveClient live = await LiveClient + .ConnectAsync(SerializationFormat.Json, TestContext) + .ConfigureAwait(false); + + string scene = $"__obsws_handle_{Guid.NewGuid():N}"[..28]; + await live + .Client.Scenes.CreateSceneAsync(new(sceneName: scene), token) + .ConfigureAwait(false); + + try + { + SceneOperations resolved = await live + .Client.Scene(scene) + .ResolveAsync(token) + .ConfigureAwait(false); + + Assert.IsTrue(resolved.Handle.IsResolved); + + await live + .Client.Inputs.CreateInputAsync( + new(inputName: $"{scene}_in", inputKind: live.Kinds.Color!, sceneName: scene), + token + ) + .ConfigureAwait(false); + + GetSceneItemListResponseData items = await live + .Client.Scene(resolved.Handle) + .GetItemListAsync(token) + .ConfigureAwait(false); + Assert.IsNotEmpty(items.SceneItems); + + SceneItemOperations item = await live + .Client.Scene(resolved.Handle) + .ItemAsync($"{scene}_in", cancellationToken: token) + .ConfigureAwait(false); + Assert.IsGreaterThan(0, item.Handle.SceneItemId); + + // A uuid keeps working across a rename; the old name does not. + await live + .Client.Scene(resolved.Handle) + .SetNameAsync($"{scene}_r", token) + .ConfigureAwait(false); + _ = await live + .Client.Scene(resolved.Handle) + .GetItemListAsync(token) + .ConfigureAwait(false); + + _ = await Assert + .ThrowsExactlyAsync(() => + live.Client.Scene(scene).GetItemListAsync(token) + ) + .ConfigureAwait(false); + + await live + .Client.Scenes.RemoveSceneAsync( + new(sceneName: $"{scene}_r"), + CancellationToken.None + ) + .ConfigureAwait(false); + } + catch + { + await live + .Client.Scenes.RemoveSceneAsync(new(sceneName: scene), CancellationToken.None) + .ConfigureAwait(false); + throw; + } + } + + [TestMethod] + [Timeout(TimeoutMs, CooperativeCancellation = true)] + public async Task ItemAsync_LiveObsMissingItem_ThrowsListingAvailable() + { + CancellationToken token = TestContext.CancellationToken; + await using LiveClient live = await LiveClient + .ConnectAsync(SerializationFormat.Json, TestContext) + .ConfigureAwait(false); + + GetSceneListResponseData scenes = await live + .Client.Scenes.GetSceneListAsync(new(), token) + .ConfigureAwait(false); + + ObsWebSocketResourceNotFoundException error = await Assert + .ThrowsExactlyAsync(() => + live + .Client.Scene(scenes.CurrentProgramSceneName!) + .ItemAsync("__obsws_absent__", cancellationToken: token) + .AsTask() + ) + .ConfigureAwait(false); + + TestContext.WriteLine(error.Message); + Assert.IsNotEmpty(error.Message); + } + + [TestMethod] + [Timeout(TimeoutMs, CooperativeCancellation = true)] + public async Task InputSourceFilterHandles_LiveObs_Resolve() + { + CancellationToken token = TestContext.CancellationToken; + await using LiveClient live = await LiveClient + .ConnectAsync(SerializationFormat.Json, TestContext) + .ConfigureAwait(false); + + GetSceneListResponseData scenes = await live + .Client.Scenes.GetSceneListAsync(new(), token) + .ConfigureAwait(false); + string input = $"__obsws_ih_{Guid.NewGuid():N}"[..24]; + + await live + .Client.Inputs.CreateInputAsync( + new( + inputName: input, + inputKind: live.Kinds.Color!, + sceneName: scenes.CurrentProgramSceneName! + ), + token + ) + .ConfigureAwait(false); + + try + { + InputOperations resolvedInput = await live + .Client.Input(input) + .ResolveAsync(token) + .ConfigureAwait(false); + Assert.IsTrue(resolvedInput.Handle.IsResolved); + + SourceOperations source = live.Client.Input(input).AsSource(); + SourceOperations resolvedSource = await live + .Client.Source(input) + .ResolveAsync(token) + .ConfigureAwait(false); + Assert.IsTrue(resolvedSource.Handle.IsResolved); + + if (live.Kinds.ColorFilter is { } filterKind) + { + await live + .Client.Filters.CreateSourceFilterAsync( + new( + filterName: "__obsws_handle_filter", + filterKind: filterKind, + sourceName: input + ), + token + ) + .ConfigureAwait(false); + + GetSourceFilterResponseData filter = await source + .Filter("__obsws_handle_filter") + .GetAsync(token) + .ConfigureAwait(false); + Assert.IsNotNull(filter); + } + + _ = await Assert + .ThrowsExactlyAsync(() => + live.Client.Input("__obsws_absent__").ResolveAsync(token).AsTask() + ) + .ConfigureAwait(false); + } + finally + { + await live + .Client.Inputs.RemoveInputAsync(new(inputName: input), CancellationToken.None) + .ConfigureAwait(false); + } + } +} diff --git a/ObsWebSocket.Tests/Integration/LiveClient.cs b/ObsWebSocket.Tests/Integration/LiveClient.cs new file mode 100644 index 0000000..a3a7715 --- /dev/null +++ b/ObsWebSocket.Tests/Integration/LiveClient.cs @@ -0,0 +1,107 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Logging; +using ObsWebSocket.Core; +using ObsWebSocket.Core.Protocol.Responses; + +namespace ObsWebSocket.Tests.Integration; + +/// +/// A connected client for one wire format, with the machine specific values a sweep needs. +/// +internal sealed class LiveClient : IAsyncDisposable +{ + private static readonly SemaphoreSlim s_outputsGate = new(1, 1); + private static GetOutputListResponseData? s_outputs; + + private readonly ServiceProvider _provider; + + private LiveClient( + ServiceProvider provider, + ObsWebSocketClient client, + ObsSourceKinds kinds, + GetOutputListResponseData outputs + ) + { + _provider = provider; + Client = client; + Kinds = kinds; + Outputs = outputs; + } + + /// The connected client. + public ObsWebSocketClient Client { get; } + + /// The input and filter kinds this OBS offers. + public ObsSourceKinds Kinds { get; } + + /// + /// The outputs, read once per test run before anything changes OBS: enumerating them after + /// the write sweep has reset video reads a freed encoder and takes OBS down (#25). Every later + /// client, whatever its wire format, reuses that first read. + /// + public GetOutputListResponseData Outputs { get; } + + /// The health check service, for the checks that exercise it. + public HealthCheckService HealthChecks => _provider.GetRequiredService(); + + /// Connects to the configured OBS using . + /// The wire format to negotiate. + /// The running test, for cancellation. + public static async Task ConnectAsync( + SerializationFormat format, + TestContext context + ) + { + (Uri uri, string? password) = ObsLiveEndpoint.Require(context); + + ServiceCollection services = new(); + _ = services.AddLogging(builder => builder.SetMinimumLevel(LogLevel.Warning)); + _ = services + .AddObsWebSocketClient(options => + { + options.ServerUri = uri; + options.Password = password; + options.Format = format; + options.AutoReconnectEnabled = false; + }) + .WithHealthCheck(); + + ServiceProvider provider = services.BuildServiceProvider(); + ObsWebSocketClient client = provider.GetRequiredService(); + + await client.ConnectAsync(context.CancellationToken).ConfigureAwait(false); + + ObsSourceKinds kinds = await ObsSourceKinds + .ResolveAsync(client, context.CancellationToken) + .ConfigureAwait(false); + GetOutputListResponseData outputs = await ReadOutputsOnceAsync( + client, + context.CancellationToken + ) + .ConfigureAwait(false); + + return new LiveClient(provider, client, kinds, outputs); + } + + private static async Task ReadOutputsOnceAsync( + ObsWebSocketClient client, + CancellationToken cancellationToken + ) + { + await s_outputsGate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + return s_outputs ??= await client + .Outputs.GetOutputListAsync(cancellationToken) + .ConfigureAwait(false); + } + finally + { + _ = s_outputsGate.Release(); + } + } + + /// + public async ValueTask DisposeAsync() => await _provider.DisposeAsync().ConfigureAwait(false); +} diff --git a/ObsWebSocket.Tests/Integration/LiveConnectionTests.cs b/ObsWebSocket.Tests/Integration/LiveConnectionTests.cs new file mode 100644 index 0000000..1c169c3 --- /dev/null +++ b/ObsWebSocket.Tests/Integration/LiveConnectionTests.cs @@ -0,0 +1,275 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Logging; +using ObsWebSocket.Core; +using ObsWebSocket.Core.Events.Generated; +using ObsWebSocket.Core.Protocol.Generated; +using ObsWebSocket.Core.Protocol.Requests; + +namespace ObsWebSocket.Tests.Integration; + +/// +/// The connection lifecycle against a real OBS: handshake, re-identification, disconnect and +/// reconnect, and the failures a wrong endpoint or password produces. The mocked transport cannot +/// prove the handshake is accepted by the server that defines it. +/// +[TestClass] +[DoNotParallelize] +[TestCategory("Integration")] +public sealed class LiveConnectionTests +{ + private const int TimeoutMs = 120_000; + + /// The context MSTest assigns, used for cancellation and run parameters. + public TestContext TestContext { get; set; } = null!; + + private ServiceProvider Build(Action configure) + { + (Uri uri, string? password) = ObsLiveEndpoint.Require(TestContext); + + ServiceCollection services = new(); + _ = services.AddLogging(builder => builder.SetMinimumLevel(LogLevel.Warning)); + _ = services + .AddObsWebSocketClient(options => + { + options.ServerUri = uri; + options.Password = password; + options.AutoReconnectEnabled = false; + configure(options); + }) + .WithHealthCheck(); + + return services.BuildServiceProvider(); + } + + [TestMethod] + [Timeout(TimeoutMs, CooperativeCancellation = true)] + public async Task ConnectAsync_AfterOwnDisconnect_Reconnects() + { + CancellationToken token = TestContext.CancellationToken; + await using ServiceProvider provider = Build(_ => { }); + ObsWebSocketClient client = provider.GetRequiredService(); + + int connects = 0; + int disconnects = 0; + client.Connected += (_, _) => Interlocked.Increment(ref connects); + client.Disconnected += (_, _) => Interlocked.Increment(ref disconnects); + + await client.ConnectAsync(token).ConfigureAwait(false); + Assert.IsTrue(client.IsConnected); + _ = await client.General.GetVersionAsync(token).ConfigureAwait(false); + + await client.DisconnectAsync(cancellationToken: token).ConfigureAwait(false); + Assert.IsFalse(client.IsConnected); + + // A second cycle on the same instance: the first connection's receive loop and context + // have to be gone before this can identify again. + await client.ConnectAsync(token).ConfigureAwait(false); + Assert.IsTrue(client.IsConnected); + _ = await client.General.GetVersionAsync(token).ConfigureAwait(false); + + await client.DisconnectAsync(cancellationToken: token).ConfigureAwait(false); + + Assert.AreEqual(2, connects); + Assert.AreEqual(2, disconnects); + } + + [TestMethod] + [Timeout(TimeoutMs, CooperativeCancellation = true)] + public async Task ConnectAsync_AlreadyConnected_Throws() + { + CancellationToken token = TestContext.CancellationToken; + await using ServiceProvider provider = Build(_ => { }); + ObsWebSocketClient client = provider.GetRequiredService(); + + await client.ConnectAsync(token).ConfigureAwait(false); + + _ = await Assert + .ThrowsExactlyAsync(() => client.ConnectAsync(token)) + .ConfigureAwait(false); + + await client.DisconnectAsync(cancellationToken: token).ConfigureAwait(false); + } + + [TestMethod] + [Timeout(TimeoutMs, CooperativeCancellation = true)] + public async Task DisconnectAsync_CalledTwice_DoesNothing() + { + CancellationToken token = TestContext.CancellationToken; + await using ServiceProvider provider = Build(_ => { }); + ObsWebSocketClient client = provider.GetRequiredService(); + + await client.ConnectAsync(token).ConfigureAwait(false); + await client.DisconnectAsync(cancellationToken: token).ConfigureAwait(false); + await client.DisconnectAsync(cancellationToken: token).ConfigureAwait(false); + + Assert.IsFalse(client.IsConnected); + } + + [TestMethod] + [Timeout(TimeoutMs, CooperativeCancellation = true)] + public async Task ReidentifyAsync_LiveObs_ChangesDeliveredEvents() + { + CancellationToken token = TestContext.CancellationToken; + await using ServiceProvider provider = Build(options => + options.EventSubscriptions = EventSubscription.None + ); + ObsWebSocketClient client = provider.GetRequiredService(); + + await client.ConnectAsync(token).ConfigureAwait(false); + Assert.AreEqual(EventSubscription.None, client.CurrentEventSubscriptions); + + TaskCompletionSource sceneChanged = new( + TaskCreationOptions.RunContinuationsAsynchronously + ); + client.CurrentProgramSceneChanged += (_, e) => + sceneChanged.TrySetResult(e.EventData.SceneName); + + string original = ( + await client + .Scenes.GetSceneListAsync(new GetSceneListRequestData(), token) + .ConfigureAwait(false) + ).CurrentProgramSceneName!; + + // A scene of its own, so both switches below are real changes whatever the collection + // holds: OBS raises nothing for a switch to the scene already active. + string own = $"__obsws_reidentify_{Guid.NewGuid():N}"[..28]; + _ = await client.Scenes.CreateSceneAsync(new(sceneName: own), token).ConfigureAwait(false); + + try + { + await client.Scenes.SetCurrentProgramSceneAsync(new(own), token).ConfigureAwait(false); + await Task.Delay(TimeSpan.FromMilliseconds(500), token).ConfigureAwait(false); + Assert.IsFalse( + sceneChanged.Task.IsCompleted, + "nothing was subscribed, so the switch must not have been delivered" + ); + + await client + .ReidentifyAsync((uint)EventSubscription.Scenes, cancellationToken: token) + .ConfigureAwait(false); + Assert.AreEqual(EventSubscription.Scenes, client.CurrentEventSubscriptions); + + await client + .Scenes.SetCurrentProgramSceneAsync(new(original), token) + .ConfigureAwait(false); + + string delivered = await sceneChanged + .Task.WaitAsync(TimeSpan.FromSeconds(10), token) + .ConfigureAwait(false); + Assert.AreEqual(original, delivered); + } + finally + { + await client + .Scenes.SetCurrentProgramSceneAsync(new(original), CancellationToken.None) + .ConfigureAwait(false); + await client + .Scenes.RemoveSceneAsync(new(sceneName: own), CancellationToken.None) + .ConfigureAwait(false); + await client + .DisconnectAsync(cancellationToken: CancellationToken.None) + .ConfigureAwait(false); + } + } + + [TestMethod] + [Timeout(TimeoutMs, CooperativeCancellation = true)] + public async Task HealthCheck_ConnectionLifecycle_FollowsState() + { + CancellationToken token = TestContext.CancellationToken; + await using ServiceProvider provider = Build(_ => { }); + ObsWebSocketClient client = provider.GetRequiredService(); + HealthCheckService health = provider.GetRequiredService(); + + HealthReport beforeConnect = await health.CheckHealthAsync(token).ConfigureAwait(false); + Assert.AreNotEqual(HealthStatus.Healthy, beforeConnect.Status); + + await client.ConnectAsync(token).ConfigureAwait(false); + HealthReport connected = await health.CheckHealthAsync(token).ConfigureAwait(false); + Assert.AreEqual(HealthStatus.Healthy, connected.Status); + + await client.DisconnectAsync(cancellationToken: token).ConfigureAwait(false); + HealthReport afterDisconnect = await health.CheckHealthAsync(token).ConfigureAwait(false); + Assert.AreNotEqual(HealthStatus.Healthy, afterDisconnect.Status); + } + + [TestMethod] + [Timeout(TimeoutMs, CooperativeCancellation = true)] + public async Task ConnectAsync_WrongPassword_ThrowsAuthenticationFailure() + { + CancellationToken token = TestContext.CancellationToken; + (_, string? password) = ObsLiveEndpoint.Require(TestContext); + if (string.IsNullOrEmpty(password)) + { + ObsLiveEndpoint.Unavailable( + TestContext, + "This OBS has authentication disabled, so there is no wrong password." + ); + } + + await using ServiceProvider provider = Build(options => + options.Password = "not-the-password" + ); + ObsWebSocketClient client = provider.GetRequiredService(); + + TaskCompletionSource failureRaised = new( + TaskCreationOptions.RunContinuationsAsynchronously + ); + client.AuthenticationFailure += (_, _) => failureRaised.TrySetResult(); + + _ = await Assert + .ThrowsExactlyAsync(() => client.ConnectAsync(token)) + .ConfigureAwait(false); + + await failureRaised.Task.WaitAsync(TimeSpan.FromSeconds(10), token).ConfigureAwait(false); + Assert.IsFalse(client.IsConnected); + } + + [TestMethod] + [Timeout(TimeoutMs, CooperativeCancellation = true)] + public async Task ConnectAsync_NoServer_FailsAfterRetryLimit() + { + CancellationToken token = TestContext.CancellationToken; + (Uri uri, _) = ObsLiveEndpoint.Require(TestContext); + + ServiceCollection services = new(); + _ = services.AddLogging(builder => builder.SetMinimumLevel(LogLevel.None)); + _ = services.AddObsWebSocketClient(options => + { + // A port nothing listens on, so the connect is refused rather than timing out. + options.ServerUri = new Uri($"ws://{uri.Host}:{FindClosedPort()}"); + options.AutoReconnectEnabled = true; + options.MaxReconnectAttempts = 2; + options.InitialReconnectDelayMs = 10; + options.MaxReconnectDelayMs = 20; + options.HandshakeTimeoutMs = 2000; + }); + + await using ServiceProvider provider = services.BuildServiceProvider(); + ObsWebSocketClient client = provider.GetRequiredService(); + + int failures = 0; + client.ConnectionFailed += (_, _) => Interlocked.Increment(ref failures); + + ObsWebSocketException failure = await Assert + .ThrowsExactlyAsync(() => client.ConnectAsync(token)) + .ConfigureAwait(false); + + Assert.IsInstanceOfType(failure.InnerException); + Assert.IsFalse(client.IsConnected); + Assert.IsGreaterThan(0, failures); + } + + private static int FindClosedPort() + { + using System.Net.Sockets.Socket probe = new( + System.Net.Sockets.AddressFamily.InterNetwork, + System.Net.Sockets.SocketType.Stream, + System.Net.Sockets.ProtocolType.Tcp + ); + probe.Bind(new System.Net.IPEndPoint(System.Net.IPAddress.Loopback, 0)); + int port = ((System.Net.IPEndPoint)probe.LocalEndPoint!).Port; + return port; + } +} diff --git a/ObsWebSocket.Tests/Integration/ObsLiveEndpoint.cs b/ObsWebSocket.Tests/Integration/ObsLiveEndpoint.cs new file mode 100644 index 0000000..7b4f6ab --- /dev/null +++ b/ObsWebSocket.Tests/Integration/ObsLiveEndpoint.cs @@ -0,0 +1,106 @@ +using System.Diagnostics.CodeAnalysis; +using Microsoft.Extensions.Configuration; + +namespace ObsWebSocket.Tests.Integration; + +/// +/// Where the live OBS is. +/// +/// +/// Resolution order is run parameters, then environment, then a local settings file. Run +/// parameters come from a .runsettings file or from --test-parameter Obs.ServerUri=..., +/// which is how CI supplies them. +/// +internal static class ObsLiveEndpoint +{ + private const string UriParameter = "Obs.ServerUri"; + private const string PasswordParameter = "Obs.Password"; + private const string RequiredParameter = "Obs.Required"; + + private static readonly Lazy<(string? Uri, string? Password, string? Required)> s_fallback = + new(ReadFallback); + + /// Reads the endpoint for the running test. + /// The running test, whose properties carry the run parameters. + public static (Uri? Uri, string? Password) Resolve(TestContext context) + { + ArgumentNullException.ThrowIfNull(context); + + string? uri = Parameter(context, UriParameter) ?? s_fallback.Value.Uri; + string? password = Parameter(context, PasswordParameter) ?? s_fallback.Value.Password; + + return ( + Uri.TryCreate(uri, UriKind.Absolute, out Uri? parsed) ? parsed : null, + string.IsNullOrEmpty(password) ? null : password + ); + } + + /// + /// Returns the endpoint, or reports the test inconclusive when none is configured, so the + /// suite still runs on a machine without OBS. + /// + /// The running test. + public static (Uri Uri, string? Password) Require(TestContext context) + { + (Uri? uri, string? password) = Resolve(context); + if (uri is null) + { + Unavailable( + context, + "No live OBS configured. Pass --test-parameter Obs.ServerUri=ws://host:4455, set Obs__ServerUri, or add testsettings.local.json." + ); + } + + return (uri, password); + } + + /// + /// Reports a missing prerequisite: inconclusive on a developer machine, a failure where the + /// run declares the live OBS required. + /// + /// + /// Inconclusive counts as skipped, not failed. Without this, a live run that lost its + /// endpoint or its fixtures would skip every live test and still pass. + /// + /// The running test. + /// What is missing. + [DoesNotReturn] + public static void Unavailable(TestContext context, string reason) + { + ArgumentNullException.ThrowIfNull(context); + + if ( + bool.TryParse( + Parameter(context, RequiredParameter) ?? s_fallback.Value.Required, + out bool required + ) && required + ) + { + Assert.Fail($"The live OBS is required for this run. {reason}"); + } + + Assert.Inconclusive(reason); + } + + private static string? Parameter(TestContext context, string name) => + context.Properties.TryGetValue(name, out object? value) + && value is string text + && !string.IsNullOrWhiteSpace(text) + ? text + : null; + + private static (string?, string?, string?) ReadFallback() + { + IConfigurationRoot configuration = new ConfigurationBuilder() + .SetBasePath(AppContext.BaseDirectory) + .AddJsonFile("testsettings.local.json", optional: true) + .AddEnvironmentVariables() + .Build(); + + return ( + configuration["Obs:ServerUri"], + configuration["Obs:Password"], + configuration["Obs:Required"] + ); + } +} diff --git a/ObsWebSocket.Tests/Integration/ObsRequestSweeps.cs b/ObsWebSocket.Tests/Integration/ObsRequestSweeps.cs new file mode 100644 index 0000000..7fe3374 --- /dev/null +++ b/ObsWebSocket.Tests/Integration/ObsRequestSweeps.cs @@ -0,0 +1,4230 @@ +using System.Buffers; +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using ObsWebSocket.Core; +using ObsWebSocket.Core.Events; +using ObsWebSocket.Core.Events.Generated; +using ObsWebSocket.Core.Protocol; +using ObsWebSocket.Core.Protocol.Common; +using ObsWebSocket.Core.Protocol.Common.FilterSettings; +using ObsWebSocket.Core.Protocol.Common.InputSettings; +using ObsWebSocket.Core.Protocol.Common.NestedTypes; +using ObsWebSocket.Core.Protocol.Generated; +using ObsWebSocket.Core.Protocol.Requests; +using ObsWebSocket.Core.Protocol.Responses; + +namespace ObsWebSocket.Tests.Integration; + +/// +/// Sends every request the protocol defines against a live OBS and reports what each one did. +/// +/// +/// Each sweep returns one entry per check rather than asserting, so a run reports every failure +/// instead of stopping at the first. +/// +internal static class ObsRequestSweeps +{ + private const string FixtureFilterName = "__obsws_rsweep_filter"; + + /// + /// Validates all three settings API modes for both InputSettings and FilterSettings. + /// + /// + /// The browser source and the gain filter are created here and removed again, so a fresh OBS + /// install exercises the same checks as a populated one. Discovering an existing input instead + /// made the result depend on the machine: the run reported the modes as failing when all that + /// was missing was a source to try them on. + /// + internal static async Task< + List<(string Label, bool Pass, string Detail)> + > ValidateSettingsModesAsync( + ObsWebSocketClient client, + GetInputListResponseData? inputs, + ObsSourceKinds kinds, + CancellationToken cancellationToken + ) + { + List<(string Label, bool Pass, string Detail)> results = []; + if (inputs is null) + { + results.Add(("Settings [all modes]", false, "GetInputList returned null")); + return results; + } + + if (kinds.Browser is null || kinds.GainFilter is null) + { + results.Add( + ("Settings [all modes]", true, $"skipped: no {kinds.Describe()} kind on this OBS") + ); + return results; + } + + // ── InputSettings ───────────────────────────────────────────────────── + const string FixtureInputName = "__obsws_settings_browser"; + const string FixtureFilter = "__obsws_settings_gain"; + + string? browserInputName = null; + string? filterSourceName = null; + string? gainFilterName = null; + + try + { + GetCurrentProgramSceneResponseData programScene = await client + .Scenes.GetCurrentProgramSceneAsync(cancellationToken) + .ConfigureAwait(false); + + await client + .Inputs.CreateInputAsync( + inputKind: kinds.Browser, + inputName: FixtureInputName, + settings: new BrowserSourceSettings( + Url: "https://obsproject.com", + Width: 800, + Height: 600 + ), + sceneName: programScene.SceneName, + sceneItemEnabled: true, + cancellationToken: cancellationToken + ) + .ConfigureAwait(false); + browserInputName = FixtureInputName; + + await client + .Filters.CreateSourceFilterAsync( + new CreateSourceFilterRequestData( + filterKind: kinds.GainFilter, + filterName: FixtureFilter, + sourceName: FixtureInputName + ), + cancellationToken + ) + .ConfigureAwait(false); + filterSourceName = FixtureInputName; + gainFilterName = FixtureFilter; + } + catch (ObsWebSocketException ex) + { + results.Add(("Settings fixtures", false, $"could not be created: {ex.Message}")); + } + + try + { + results.AddRange( + await RunSettingsModeChecksAsync( + client, + browserInputName, + filterSourceName, + gainFilterName, + cancellationToken + ) + .ConfigureAwait(false) + ); + } + finally + { + // Removing the input takes its filter and its scene item with it. + if (browserInputName is not null) + { + try + { + await client + .Inputs.RemoveInputAsync( + new(inputName: browserInputName), + CancellationToken.None + ) + .ConfigureAwait(false); + } + catch (ObsWebSocketException) + { + // Nothing useful to do about a fixture that will not go away. The next run + // recreates it by the same name and OBS rejects the duplicate visibly. + } + } + } + + return results; + } + + /// + /// Exercises the modern conveniences against a scene and input this method creates itself, + /// so the run does not depend on any particular OBS layout. Everything it makes is removed + /// again, whether the checks pass or not. + /// + internal static async Task< + List<(string Label, bool Pass, string Detail)> + > ValidateModernApisAsync( + ObsWebSocketClient client, + HealthCheckService healthChecks, + ObsSourceKinds kinds, + GetOutputListResponseData outputs, + CancellationToken cancellationToken + ) + { + List<(string Label, bool Pass, string Detail)> results = []; + + string suffix = Guid.NewGuid().ToString("N")[..8]; + string sceneName = $"__obsws_validation_{suffix}"; + string inputName = $"__obsws_input_{suffix}"; + + GetSceneListResponseData? sceneList = await client + .Scenes.GetSceneListAsync(new(), cancellationToken) + .ConfigureAwait(false); + string originalScene = sceneList?.CurrentProgramSceneName ?? string.Empty; + + bool sceneCreated = false; + bool inputCreated = false; + + try + { + await client + .Scenes.CreateSceneAsync(new CreateSceneRequestData(sceneName), cancellationToken) + .ConfigureAwait(false); + sceneCreated = true; + + results.Add( + await TrySettingsCheckAsync( + "SceneExistsAsync", + async () => + { + bool present = await client + .Scenes.SceneExistsAsync(sceneName, cancellationToken) + .ConfigureAwait(false); + bool absent = await client + .Scenes.SceneExistsAsync(sceneName + "__nope", cancellationToken) + .ConfigureAwait(false); + return (present && !absent, $"present={present}, absent={!absent}"); + } + ) + .ConfigureAwait(false) + ); + + // A media source carries audio, so the volume and media transport helpers apply. + _ = await client + .Inputs.CreateInputAsync( + "ffmpeg_source", + inputName, + new MediaSourceSettings(IsLocalFile: true), + sceneName: sceneName, + cancellationToken: cancellationToken + ) + .ConfigureAwait(false); + inputCreated = true; + + results.Add( + await TrySettingsCheckAsync( + "FindSceneItemIdAsync", + async () => + { + double? hit = await client + .SceneItems.FindSceneItemIdAsync( + sceneName, + inputName, + cancellationToken + ) + .ConfigureAwait(false); + double? miss = await client + .SceneItems.FindSceneItemIdAsync( + sceneName, + "__not_here__", + cancellationToken + ) + .ConfigureAwait(false); + return ( + hit is not null && miss is null, + $"hit={hit}, miss={(miss is null ? "null" : "unexpected")}" + ); + } + ) + .ConfigureAwait(false) + ); + + results.Add( + await TrySettingsCheckAsync( + "SetSceneItemEnabledAsync (toggle)", + async () => + { + bool off = await client + .SceneItems.SetSceneItemEnabledAsync( + sceneName, + inputName, + false, + cancellationToken + ) + .ConfigureAwait(false); + bool toggled = await client + .SceneItems.SetSceneItemEnabledAsync( + sceneName, + inputName, + null, + cancellationToken + ) + .ConfigureAwait(false); + return (!off && toggled, $"set false -> {off}, toggled -> {toggled}"); + } + ) + .ConfigureAwait(false) + ); + + results.Add( + await TrySettingsCheckAsync( + "SetInputVolumeDbAsync", + async () => + { + await client + .Inputs.SetInputVolumeDbAsync(inputName, -6, cancellationToken) + .ConfigureAwait(false); + GetInputVolumeResponseData? volume = await client + .Inputs.GetInputVolumeAsync( + new GetInputVolumeRequestData(inputName: inputName), + cancellationToken + ) + .ConfigureAwait(false); + double db = volume?.InputVolumeDb ?? double.NaN; + return (Math.Abs(db + 6) < 0.5, $"db={db:0.##}"); + } + ) + .ConfigureAwait(false) + ); + + results.Add( + await TrySettingsCheckAsync( + "Media transport (typed enum)", + async () => + { + await client + .MediaInputs.TriggerMediaActionAsync( + inputName, + MediaInputAction.Stop, + cancellationToken + ) + .ConfigureAwait(false); + + // Read the state back, so this proves the action landed rather than only that + // the request was accepted. + GetMediaInputStatusResponseData? status = await client + .MediaInputs.GetMediaInputStatusAsync( + new GetMediaInputStatusRequestData(inputName: inputName), + cancellationToken + ) + .ConfigureAwait(false); + + string? state = status?.MediaState; + bool stopped = + state is not null + && state.Contains("STOPPED", StringComparison.Ordinal) + || state is not null + && state.Contains("NONE", StringComparison.Ordinal); + + return ( + stopped, + $"sent {MediaInputAction.Stop.ToWireValue()}, state={state}" + ); + } + ) + .ConfigureAwait(false) + ); + + results.Add( + await TrySettingsCheckAsync( + "Event stream (await foreach)", + async () => + { + using CancellationTokenSource streamCts = + CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + streamCts.CancelAfter(TimeSpan.FromSeconds(10)); + + List observed = []; + Task consume = Task.Run( + async () => + { + try + { + await foreach ( + CurrentProgramSceneChangedEventArgs sceneEvent in client + .Scenes.CurrentProgramSceneChangedStream( + cancellationToken: streamCts.Token + ) + .ConfigureAwait(false) + ) + { + observed.Add( + sceneEvent.EventData.SceneName ?? string.Empty + ); + if (observed.Count >= 2) + { + await streamCts.CancelAsync().ConfigureAwait(false); + } + } + } + catch (OperationCanceledException) + { + // Expected once both switches are seen or the window elapses. + } + }, + CancellationToken.None + ); + + await Task.Delay(250, cancellationToken).ConfigureAwait(false); + await client + .Scenes.SwitchProgramSceneAsync( + sceneName, + cancellationToken: cancellationToken + ) + .ConfigureAwait(false); + await Task.Delay(400, cancellationToken).ConfigureAwait(false); + if (!string.IsNullOrEmpty(originalScene)) + { + await client + .Scenes.SwitchProgramSceneAsync( + originalScene, + cancellationToken: cancellationToken + ) + .ConfigureAwait(false); + } + + await consume.ConfigureAwait(false); + return ( + observed.Count >= 2, + $"observed {observed.Count}: {string.Join(" -> ", observed)}" + ); + } + ) + .ConfigureAwait(false) + ); + + results.Add( + await TrySettingsCheckAsync( + "WaitForEventAsync (timeout overload)", + async () => + { + Task wait = + client.WaitForEventAsync( + TimeSpan.FromSeconds(5), + cancellationToken + ); + _ = await client + .SceneItems.SetSceneItemEnabledAsync( + sceneName, + inputName, + false, + cancellationToken + ) + .ConfigureAwait(false); + try + { + SceneItemEnableStateChangedEventArgs observed = + await wait.ConfigureAwait(false); + return (true, $"enabled={observed.EventData.SceneItemEnabled}"); + } + catch (TimeoutException) + { + return (false, "timed out"); + } + } + ) + .ConfigureAwait(false) + ); + + results.Add( + await TrySettingsCheckAsync( + "Typed batch builder", + async () => + { + ObsBatchBuilder batch = new(); + BatchRef versionRef = + batch.General.GetVersion(); + _ = batch.General.Sleep(new SleepRequestData(sleepMillis: 25)); + BatchRef scenesRef = + batch.Scenes.GetSceneList(new GetSceneListRequestData()); + BatchRef statsRef = batch.General.GetStats(); + + BatchResults typedBatch = await client + .CallBatchAsync( + batch, + executionType: RequestBatchExecutionType.SerialRealtime, + haltOnFailure: false, + cancellationToken: cancellationToken + ) + .ConfigureAwait(false); + + // Each result is read through the reference its request handed back, so neither + // the position nor the response type is restated here. + GetVersionResponseData version = typedBatch.Get(versionRef); + GetSceneListResponseData scenes = typedBatch.Get(scenesRef); + GetStatsResponseData stats = typedBatch.Get(statsRef); + + return ( + typedBatch.Count == 4 + && typedBatch.AllSucceeded() + && version.ObsVersion is not null + && scenes.Scenes is not null, + $"{typedBatch.Count} result(s), OBS {version.ObsVersion}, {scenes.Scenes?.Count} scene(s), {stats.ActiveFps:0} fps" + ); + } + ) + .ConfigureAwait(false) + ); + + results.Add( + await TrySettingsCheckAsync( + "Batch order and duplicates", + async () => + { + // Repeats one request type with different payloads and interleaves others, so a + // result can only be matched to its request by position. + GetSceneListResponseData? allScenes = await client + .Scenes.GetSceneListAsync( + new GetSceneListRequestData(), + cancellationToken + ) + .ConfigureAwait(false); + string otherScene = allScenes! + .Scenes!.Select(scene => scene.SceneName!) + .First(name => + !string.Equals(name, sceneName, StringComparison.Ordinal) + ); + + // The same request type appears three times with two different payloads, so a + // result can only be matched to its request through the reference it returned. + ObsBatchBuilder mixedBatch = new(); + BatchRef firstRef = + mixedBatch.SceneItems.GetSceneItemList( + new GetSceneItemListRequestData(sceneName: sceneName) + ); + BatchRef versionRef = + mixedBatch.General.GetVersion(); + BatchRef secondRef = + mixedBatch.SceneItems.GetSceneItemList( + new GetSceneItemListRequestData(sceneName: otherScene) + ); + BatchRef thirdRef = + mixedBatch.SceneItems.GetSceneItemList( + new GetSceneItemListRequestData(sceneName: sceneName) + ); + _ = mixedBatch.General.GetStats(); + + BatchResults mixed = await client + .CallBatchAsync( + mixedBatch, + executionType: RequestBatchExecutionType.SerialRealtime, + haltOnFailure: false, + cancellationToken: cancellationToken + ) + .ConfigureAwait(false); + + if (mixed.Count != 5 || !mixed.AllSucceeded()) + { + return ( + false, + $"expected 5 successes, got {mixed.Count} with {mixed.GetFailures().Count()} failure(s)" + ); + } + + GetSceneItemListResponseData first = mixed.Get(firstRef); + GetVersionResponseData version = mixed.Get(versionRef); + GetSceneItemListResponseData second = mixed.Get(secondRef); + GetSceneItemListResponseData third = mixed.Get(thirdRef); + + // The two lookups of the same scene must agree, and differ from the other scene. + int firstCount = first.SceneItems?.Count ?? -1; + int secondCount = second.SceneItems?.Count ?? -1; + int thirdCount = third.SceneItems?.Count ?? -1; + bool repeatsAgree = firstCount == thirdCount; + bool distinguishable = + firstCount != secondCount + || !string.Equals(sceneName, otherScene, StringComparison.Ordinal); + + return ( + repeatsAgree && distinguishable && version.ObsVersion is not null, + $"[{firstCount}, v{version.ObsVersion}, {secondCount}, {thirdCount}] repeats agree = {repeatsAgree}" + ); + } + ) + .ConfigureAwait(false) + ); + + results.Add( + await TrySettingsCheckAsync( + "Batch partial failure", + async () => + { + // haltOnFailure false, so the good requests either side of a bad one still run. + ObsBatchBuilder partialBatch = new(); + BatchRef goodRef = + partialBatch.General.GetVersion(); + BatchRef badRef = + partialBatch.SceneItems.GetSceneItemList( + new GetSceneItemListRequestData(sceneName: "__no_such_scene__") + ); + _ = partialBatch.General.GetStats(); + + BatchResults partial = await client + .CallBatchAsync( + partialBatch, + executionType: RequestBatchExecutionType.SerialRealtime, + haltOnFailure: false, + cancellationToken: cancellationToken + ) + .ConfigureAwait(false); + + RequestResponsePayload[] failures = [.. partial.GetFailures()]; + if (partial.Count != 3 || failures.Length != 1) + { + return ( + false, + $"{partial.Count} result(s), {failures.Length} failure(s)" + ); + } + + // GetRequiredData surfaces the OBS status rather than a null payload. + string caught; + try + { + _ = partial.Get(badRef); + caught = "no exception"; + } + catch (ObsWebSocketRequestException ex) + { + caught = $"code {(int?)ex.StatusCode}"; + } + + // TryGet reports the failure without throwing. + bool tryGetReportedFailure = !partial.TryGet(badRef, out _); + bool neighboursOk = + tryGetReportedFailure + && partial.Get(goodRef).ObsVersion is not null; + + return ( + neighboursOk + && caught.StartsWith("code ", StringComparison.Ordinal), + $"1 failed ({caught}), neighbours ran" + ); + } + ) + .ConfigureAwait(false) + ); + + results.Add( + await TrySettingsCheckAsync( + "Event stream buffering", + async () => + { + // A stream keeps the newest events when a consumer falls behind rather than + // stalling the receive loop, so a small capacity drops the oldest. + using CancellationTokenSource cts = + CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + cts.CancelAfter(TimeSpan.FromSeconds(10)); + + IAsyncEnumerator enumerator = + client + .SceneItems.SceneItemEnableStateChangedStream( + capacity: 2, + cancellationToken: cts.Token + ) + .GetAsyncEnumerator(cts.Token); + + try + { + ValueTask pending = enumerator.MoveNextAsync(); + + // Toggle more times than the buffer holds. + for (int i = 0; i < 4; i++) + { + _ = await client + .SceneItems.SetSceneItemEnabledAsync( + sceneName, + inputName, + i % 2 == 0, + cancellationToken + ) + .ConfigureAwait(false); + } + + bool first = await pending.ConfigureAwait(false); + return ( + first, + first + ? "buffered and delivered under capacity pressure" + : "no event" + ); + } + finally + { + await enumerator.DisposeAsync().ConfigureAwait(false); + } + } + ) + .ConfigureAwait(false) + ); + + results.Add( + await TrySettingsCheckAsync( + "Single-request values (non-batch)", + async () => + { + // The same response types that come back empty inside a batch, fetched singly. + GetSceneItemListResponseData? items = await client + .SceneItems.GetSceneItemListAsync( + new GetSceneItemListRequestData(sceneName: sceneName), + cancellationToken + ) + .ConfigureAwait(false); + GetStatsResponseData? st = await client + .General.GetStatsAsync(cancellationToken) + .ConfigureAwait(false); + GetVersionResponseData? ver = await client + .General.GetVersionAsync(cancellationToken) + .ConfigureAwait(false); + + int itemCount = items?.SceneItems?.Count ?? -1; + double fps = st?.ActiveFps ?? 0; + + return ( + itemCount >= 0 && fps > 0 && ver?.ObsVersion is not null, + $"items={itemCount}, {fps:0} fps, v={ver?.ObsVersion}" + ); + } + ) + .ConfigureAwait(false) + ); + + results.Add( + await TrySettingsCheckAsync( + "Batch parallel execution", + async () => + { + // OBS pairs each result with another request's response data under parallel + // execution, so reading by reference must refuse rather than return the wrong + // request's payload. + ObsBatchBuilder par = new(); + BatchRef v = par.General.GetVersion(); + _ = par.SceneItems.GetSceneItemList( + new GetSceneItemListRequestData(sceneName: sceneName) + ); + + BatchResults r = await client + .CallBatchAsync( + par, + executionType: RequestBatchExecutionType.Parallel, + haltOnFailure: false, + cancellationToken: cancellationToken + ) + .ConfigureAwait(false); + + string guarded; + try + { + _ = r.Get(v); + guarded = "returned data"; + } + catch (ObsWebSocketException ex) + { + guarded = ex.Message.Contains("Parallel", StringComparison.Ordinal) + ? "refused" + : "threw: " + ex.Message; + } + + return ( + r.Count == 2 + && guarded == "refused" + && !r.TryGet(v, out GetVersionResponseData? _), + $"{r.Count} raw result(s), reference {guarded}" + ); + } + ) + .ConfigureAwait(false) + ); + + results.Add( + await TrySettingsCheckAsync( + "Batch halt on failure", + async () => + { + ObsBatchBuilder halt = new(); + BatchRef first = halt.General.GetVersion(); + BatchRef bad = + halt.SceneItems.GetSceneItemList( + new GetSceneItemListRequestData(sceneName: "__no_such_scene__") + ); + BatchRef never = halt.General.GetStats(); + + BatchResults r = await client + .CallBatchAsync( + halt, + executionType: RequestBatchExecutionType.SerialRealtime, + haltOnFailure: true, + cancellationToken: cancellationToken + ) + .ConfigureAwait(false); + + bool firstOk = r.Get(first).ObsVersion is not null; + bool badRejected = !r.TryGet(bad, out GetSceneItemListResponseData? _); + + // The third request never ran, so reading it explains itself rather than + // returning someone else's result. + string neverMsg; + try + { + _ = r.Get(never); + neverMsg = "returned a result"; + } + catch (ObsWebSocketException ex) + { + neverMsg = ex.Message.Contains( + "never ran", + StringComparison.Ordinal + ) + ? "explained" + : "threw: " + ex.Message; + } + + return ( + firstOk && badRejected && neverMsg == "explained", + $"{r.Count} result(s), unrun request {neverMsg}" + ); + } + ) + .ConfigureAwait(false) + ); + + results.Add( + await TrySettingsCheckAsync( + "SetInputVolumeMulAsync", + async () => + { + GetInputVolumeResponseData? before = await client + .Inputs.GetInputVolumeAsync( + new GetInputVolumeRequestData(inputName: inputName), + cancellationToken + ) + .ConfigureAwait(false); + double original = before!.InputVolumeMul; + + await client + .Inputs.SetInputVolumeMulAsync(inputName, 0.5, cancellationToken) + .ConfigureAwait(false); + GetInputVolumeResponseData? after = await client + .Inputs.GetInputVolumeAsync( + new GetInputVolumeRequestData(inputName: inputName), + cancellationToken + ) + .ConfigureAwait(false); + double mul = after!.InputVolumeMul; + + await client + .Inputs.SetInputVolumeMulAsync( + inputName, + original, + cancellationToken + ) + .ConfigureAwait(false); + return (Math.Abs(mul - 0.5) < 0.01, $"mul={mul:0.###}"); + } + ) + .ConfigureAwait(false) + ); + + results.Add( + await TrySettingsCheckAsync( + "SwitchProgramSceneAsync", + async () => + { + await client + .Scenes.SwitchProgramSceneAsync( + sceneName, + cancellationToken: cancellationToken + ) + .ConfigureAwait(false); + GetSceneListResponseData? mid = await client + .Scenes.GetSceneListAsync( + new GetSceneListRequestData(), + cancellationToken + ) + .ConfigureAwait(false); + bool switched = string.Equals( + mid?.CurrentProgramSceneName, + sceneName, + StringComparison.Ordinal + ); + + await client + .Scenes.SwitchProgramSceneAsync( + originalScene, + cancellationToken: cancellationToken + ) + .ConfigureAwait(false); + GetSceneListResponseData? restored = await client + .Scenes.GetSceneListAsync( + new GetSceneListRequestData(), + cancellationToken + ) + .ConfigureAwait(false); + + return ( + switched + && string.Equals( + restored?.CurrentProgramSceneName, + originalScene, + StringComparison.Ordinal + ), + $"switched={switched}, restored to '{restored?.CurrentProgramSceneName}'" + ); + } + ) + .ConfigureAwait(false) + ); + + results.Add( + await TrySettingsCheckAsync( + "FindSceneItemIdAsync", + async () => + { + long? id = await client + .SceneItems.FindSceneItemIdAsync( + sceneName, + inputName, + cancellationToken + ) + .ConfigureAwait(false); + long? miss = await client + .SceneItems.FindSceneItemIdAsync( + sceneName, + "__absent__", + cancellationToken + ) + .ConfigureAwait(false); + + return ( + id is not null && miss is null, + $"id={id}, miss={(miss is null ? "null" : "unexpected")}" + ); + } + ) + .ConfigureAwait(false) + ); + + results.Add( + await TrySettingsCheckAsync( + "Screenshot helpers", + async () => + { + byte[]? bytes = await client + .Sources.GetSourceScreenshotBytesAsync( + sceneName, + "png", + cancellationToken: cancellationToken + ) + .ConfigureAwait(false); + + string path = Path.Combine( + Path.GetTempPath(), + $"obsws_{Guid.NewGuid():N}.png" + ); + try + { + await client + .Sources.SaveSourceScreenshotToFileAsync( + sceneName, + path, + "png", + cancellationToken: cancellationToken + ) + .ConfigureAwait(false); + + // A PNG starts with the eight byte signature, so this checks real image data + // rather than merely that the call returned. + byte[] written = await File.ReadAllBytesAsync( + path, + cancellationToken + ) + .ConfigureAwait(false); + bool pngOnDisk = + written.Length > 8 + && written[0] == 0x89 + && written[1] == 0x50 + && written[2] == 0x4E + && written[3] == 0x47; + bool pngInMemory = + bytes is { Length: > 8 } + && bytes[0] == 0x89 + && bytes[1] == 0x50 + && bytes[2] == 0x4E + && bytes[3] == 0x47; + + return ( + pngInMemory && pngOnDisk, + $"{bytes?.Length ?? 0} bytes in memory, {written.Length} on disk" + ); + } + finally + { + if (File.Exists(path)) + { + File.Delete(path); + } + } + } + ) + .ConfigureAwait(false) + ); + + results.Add( + await TrySettingsCheckAsync( + "Ensure profile and scene collection", + async () => + { + // Asking for the one already active proves the check without disrupting OBS, + // since switching either of these reloads the whole configuration. + GetProfileListResponseData? profiles = await client + .Config.GetProfileListAsync(cancellationToken) + .ConfigureAwait(false); + GetSceneCollectionListResponseData? collections = await client + .Config.GetSceneCollectionListAsync(cancellationToken) + .ConfigureAwait(false); + + bool profileOk = await client + .Config.EnsureProfileActiveAsync( + profiles!.CurrentProfileName!, + cancellationToken + ) + .ConfigureAwait(false); + bool collectionOk = await client + .Config.EnsureSceneCollectionActiveAsync( + collections!.CurrentSceneCollectionName!, + cancellationToken + ) + .ConfigureAwait(false); + bool absent = await client + .Config.EnsureProfileActiveAsync( + "__no_such_profile__", + cancellationToken + ) + .ConfigureAwait(false); + + return ( + profileOk && collectionOk && !absent, + $"profile={profiles.CurrentProfileName}, collection={collections.CurrentSceneCollectionName}, absent reported {absent}" + ); + } + ) + .ConfigureAwait(false) + ); + + results.Add( + await TrySettingsCheckAsync( + "Media transport shorthands", + async () => + { + await client + .MediaInputs.PlayMediaAsync(inputName, cancellationToken) + .ConfigureAwait(false); + await client + .MediaInputs.PauseMediaAsync(inputName, cancellationToken) + .ConfigureAwait(false); + await client + .MediaInputs.RestartMediaAsync(inputName, cancellationToken) + .ConfigureAwait(false); + await client + .MediaInputs.StopMediaAsync(inputName, cancellationToken) + .ConfigureAwait(false); + + GetMediaInputStatusResponseData? status = await client + .MediaInputs.GetMediaInputStatusAsync( + new GetMediaInputStatusRequestData(inputName: inputName), + cancellationToken + ) + .ConfigureAwait(false); + + return (status is not null, $"state={status?.MediaState}"); + } + ) + .ConfigureAwait(false) + ); + + // Disabled: toggling the virtual camera takes down this OBS install. The fault is in + // the Stream Deck plugin (streamdeckpluginobs32.dll appears at the fault address and + // in every frame above it), not in OBS or in this library. Re-enable once that plugin + // is removed. + // results.Add( + // await TrySettingsCheckAsync( + // "Virtual camera toggle", + // async () => + // { + // bool before = await client + // .Outputs.IsVirtualCamActiveAsync(cancellationToken) + // .ConfigureAwait(false); + + // bool? turnedOn = await client + // .Outputs.SetVirtualCamActiveAndWaitAsync( + // !before, + // cancellationToken: cancellationToken + // ) + // .ConfigureAwait(false); + // bool observed = await client + // .Outputs.IsVirtualCamActiveAsync(cancellationToken) + // .ConfigureAwait(false); + + // // Put it back the way it was found. + // _ = await client + // .Outputs.SetVirtualCamActiveAndWaitAsync( + // before, + // cancellationToken: cancellationToken + // ) + // .ConfigureAwait(false); + // bool restored = await client + // .Outputs.IsVirtualCamActiveAsync(cancellationToken) + // .ConfigureAwait(false); + + // return ( + // turnedOn == !before && observed == !before && restored == before, + // $"{before} -> {observed} -> {restored}" + // ); + // } + // ) + // .ConfigureAwait(false) + // ); + + results.Add( + await TrySettingsCheckAsync( + "Integer fields round trip", + async () => + { + // The protocol calls every number "Number", so these fields used to + // arrive as double. Writing one and reading it back proves the + // retype survives the wire in both directions, which matters most + // for MessagePack, where an int and a float are different encodings. + long itemId = + await client + .SceneItems.FindSceneItemIdAsync( + sceneName, + inputName, + cancellationToken + ) + .ConfigureAwait(false) + ?? throw new InvalidOperationException( + $"'{inputName}' is not in '{sceneName}'." + ); + + GetSceneItemIndexResponseData originalIndex = await client + .SceneItems.GetSceneItemIndexAsync( + new GetSceneItemIndexRequestData( + sceneItemId: itemId, + sceneName: sceneName + ), + cancellationToken + ) + .ConfigureAwait(false); + + await client + .SceneItems.SetSceneItemIndexAsync( + new SetSceneItemIndexRequestData( + sceneItemId: itemId, + sceneItemIndex: 0, + sceneName: sceneName + ), + cancellationToken + ) + .ConfigureAwait(false); + + GetSceneItemIndexResponseData afterIndex = await client + .SceneItems.GetSceneItemIndexAsync( + new GetSceneItemIndexRequestData( + sceneItemId: itemId, + sceneName: sceneName + ), + cancellationToken + ) + .ConfigureAwait(false); + + await client + .SceneItems.SetSceneItemIndexAsync( + new SetSceneItemIndexRequestData( + sceneItemId: itemId, + sceneItemIndex: originalIndex.SceneItemIndex, + sceneName: sceneName + ), + cancellationToken + ) + .ConfigureAwait(false); + + // A negative value, since OBS accepts negative sync offsets and a + // sign error would otherwise go unnoticed. + GetInputAudioSyncOffsetResponseData originalOffset = await client + .Inputs.GetInputAudioSyncOffsetAsync( + new GetInputAudioSyncOffsetRequestData(inputName: inputName), + cancellationToken + ) + .ConfigureAwait(false); + + await client + .Inputs.SetInputAudioSyncOffsetAsync( + new SetInputAudioSyncOffsetRequestData( + inputAudioSyncOffset: -125, + inputName: inputName + ), + cancellationToken + ) + .ConfigureAwait(false); + + GetInputAudioSyncOffsetResponseData afterOffset = await client + .Inputs.GetInputAudioSyncOffsetAsync( + new GetInputAudioSyncOffsetRequestData(inputName: inputName), + cancellationToken + ) + .ConfigureAwait(false); + + await client + .Inputs.SetInputAudioSyncOffsetAsync( + new SetInputAudioSyncOffsetRequestData( + inputAudioSyncOffset: originalOffset.InputAudioSyncOffset, + inputName: inputName + ), + cancellationToken + ) + .ConfigureAwait(false); + + return ( + afterIndex.SceneItemIndex == 0 + && afterOffset.InputAudioSyncOffset == -125, + $"index {originalIndex.SceneItemIndex} -> {afterIndex.SceneItemIndex}, " + + $"syncOffset {originalOffset.InputAudioSyncOffset} -> {afterOffset.InputAudioSyncOffset}" + ); + } + ) + .ConfigureAwait(false) + ); + + results.Add( + await TrySettingsCheckAsync( + "Preview scene helpers", + async () => + { + // Preview only exists in Studio Mode, so turn it on for the check and + // put it back however it was found. + GetStudioModeEnabledResponseData studio = await client + .Ui.GetStudioModeEnabledAsync(cancellationToken) + .ConfigureAwait(false); + if (!studio.StudioModeEnabled) + { + await client + .Ui.SetStudioModeEnabledAsync(new(true), cancellationToken) + .ConfigureAwait(false); + } + + try + { + // Every switch waits for the event confirming it. OBS points + // Preview at the Program scene while enabling Studio Mode, and + // that lands after StudioModeStateChanged, so a switch that does + // not wait for its own confirmation gets silently undone. + await client + .Scenes.SwitchPreviewSceneAndWaitAsync( + sceneName, + cancellationToken: cancellationToken + ) + .ConfigureAwait(false); + GetCurrentPreviewSceneResponseData preview = await client + .Scenes.GetCurrentPreviewSceneAsync(cancellationToken) + .ConfigureAwait(false); + + // The plain overload, confirmed by waiting on the event directly. + Task back = + client.WaitForEventAsync( + e => + string.Equals( + e.EventData.SceneName, + originalScene, + StringComparison.Ordinal + ), + TimeSpan.FromSeconds(5), + cancellationToken + ); + await client + .Scenes.SwitchPreviewSceneAsync( + originalScene, + cancellationToken + ) + .ConfigureAwait(false); + _ = await back.ConfigureAwait(false); + + GetCurrentPreviewSceneResponseData restored = await client + .Scenes.GetCurrentPreviewSceneAsync(cancellationToken) + .ConfigureAwait(false); + + bool ok = + string.Equals( + preview.SceneName, + sceneName, + StringComparison.Ordinal + ) + && string.Equals( + restored.SceneName, + originalScene, + StringComparison.Ordinal + ); + + return ( + ok, + $"wanted {sceneName} got {preview.SceneName}, " + + $"then wanted {originalScene} got {restored.SceneName}" + ); + } + finally + { + if (!studio.StudioModeEnabled) + { + await client + .Ui.SetStudioModeEnabledAsync(new(false), cancellationToken) + .ConfigureAwait(false); + } + } + } + ) + .ConfigureAwait(false) + ); + + results.Add( + await TrySettingsCheckAsync( + "SourceExistsAsync", + async () => + { + bool present = await client + .Sources.SourceExistsAsync(inputName, cancellationToken) + .ConfigureAwait(false); + bool absent = await client + .Sources.SourceExistsAsync("__absent__", cancellationToken) + .ConfigureAwait(false); + + return (present && !absent, $"present={present}, absent={absent}"); + } + ) + .ConfigureAwait(false) + ); + + results.Add( + await TrySettingsCheckAsync( + "SetInputMutesAsync", + async () => + { + GetInputMuteResponseData before = await client + .Inputs.GetInputMuteAsync( + new(inputName: inputName), + cancellationToken + ) + .ConfigureAwait(false); + + // One real input beside one that does not exist, so the returned + // results have to show a success next to a failure. + BatchResults muteResults = await client + .Inputs.SetInputMutesAsync( + [(inputName, !before.InputMuted), ("__absent__", true)], + cancellationToken + ) + .ConfigureAwait(false); + + GetInputMuteResponseData after = await client + .Inputs.GetInputMuteAsync( + new(inputName: inputName), + cancellationToken + ) + .ConfigureAwait(false); + + await client + .Inputs.SetInputMutesAsync( + [(inputName, before.InputMuted)], + cancellationToken + ) + .ConfigureAwait(false); + + bool ok = + muteResults.Count == 2 + && muteResults[0].RequestStatus.Result + && !muteResults[1].RequestStatus.Result + && after.InputMuted == !before.InputMuted; + + return ( + ok, + $"{muteResults.Count} results, muted {before.InputMuted} -> {after.InputMuted}" + ); + } + ) + .ConfigureAwait(false) + ); + + results.Add( + await TrySettingsCheckAsync( + "Transition settings read", + async () => + { + GetCurrentSceneTransitionResponseData current = await client + .Transitions.GetCurrentSceneTransitionAsync(cancellationToken) + .ConfigureAwait(false); + // The typed Get*SettingsAsync helpers deserialize into a settings + // record; the generated request is the way to read the raw JSON. + // A transition with nothing to configure, such as Fade, legitimately + // reports no settings, so the name and kind are what is asserted. + JsonElement? settings = current.TransitionSettings; + + return ( + !string.IsNullOrEmpty(current.TransitionName) + && !string.IsNullOrEmpty(current.TransitionKind), + $"{current.TransitionName} ({current.TransitionKind}), " + + $"settings {(settings is null ? "none" : "present")}" + ); + } + ) + .ConfigureAwait(false) + ); + + results.Add( + await TrySettingsCheckAsync( + "Canvas screenshot helper", + async () => + { + byte[]? bytes = await client + .Sources.GetSourceScreenshotOnCanvasBytesAsync( + sceneName, + "png", + cancellationToken: cancellationToken + ) + .ConfigureAwait(false); + + bool png = + bytes is { Length: > 8 } + && bytes[0] == 0x89 + && bytes[1] == 0x50 + && bytes[2] == 0x4E + && bytes[3] == 0x47; + + return (png, $"{bytes?.Length ?? 0} bytes at canvas size"); + } + ) + .ConfigureAwait(false) + ); + + results.Add( + await TrySettingsCheckAsync( + "Parallel batch, verdict without attribution", + async () => + { + // A parallel batch of writes is the case Parallel is actually good + // for: OBS mispairs the rows, but a verdict over all of them does not + // depend on which row is which. + ObsBatchBuilder par = new(); + _ = par.Inputs.SetInputMute( + new SetInputMuteRequestData(inputName: inputName, inputMuted: true) + ); + _ = par.Inputs.SetInputMute( + new SetInputMuteRequestData(inputName: inputName, inputMuted: false) + ); + + BatchResults ok = await client + .CallBatchAsync( + par, + executionType: RequestBatchExecutionType.Parallel, + haltOnFailure: false, + cancellationToken: cancellationToken + ) + .ConfigureAwait(false); + + // Same again with one request that cannot succeed, so the count of + // failures is checked as well as the all-succeeded verdict. + ObsBatchBuilder mixed = new(); + _ = mixed.Inputs.SetInputMute( + new SetInputMuteRequestData(inputName: inputName, inputMuted: false) + ); + _ = mixed.Inputs.SetInputMute( + new SetInputMuteRequestData( + inputName: "__absent__", + inputMuted: true + ) + ); + + BatchResults partial = await client + .CallBatchAsync( + mixed, + executionType: RequestBatchExecutionType.Parallel, + haltOnFailure: false, + cancellationToken: cancellationToken + ) + .ConfigureAwait(false); + + int failures = partial.GetFailures().Count(); + + return ( + ok.AllSucceeded() && !partial.AllSucceeded() && failures == 1, + $"all-ok verdict {ok.AllSucceeded()}, mixed verdict " + + $"{partial.AllSucceeded()} with {failures} failure(s)" + ); + } + ) + .ConfigureAwait(false) + ); + + results.Add( + await TrySettingsCheckAsync( + "Concurrent requests keep their own results", + async () => + { + // The answer to "how do I run things in parallel" once a parallel + // batch is ruled out for reads. The client multiplexes on request id. + Task version = client.General.GetVersionAsync( + cancellationToken + ); + Task video = + client.Config.GetVideoSettingsAsync(cancellationToken); + Task itemsHere = + client.SceneItems.GetSceneItemListAsync( + new GetSceneItemListRequestData(sceneName: sceneName), + cancellationToken + ); + Task itemsThere = + client.SceneItems.GetSceneItemListAsync( + new GetSceneItemListRequestData(sceneName: originalScene), + cancellationToken + ); + + await Task.WhenAll(version, video, itemsHere, itemsThere) + .ConfigureAwait(false); + + // Each answer has to match what the same request returns on its own. + GetVersionResponseData serialVersion = await client + .General.GetVersionAsync(cancellationToken) + .ConfigureAwait(false); + GetSceneItemListResponseData serialHere = await client + .SceneItems.GetSceneItemListAsync( + new GetSceneItemListRequestData(sceneName: sceneName), + cancellationToken + ) + .ConfigureAwait(false); + + bool ok = + string.Equals( + version.Result.ObsVersion, + serialVersion.ObsVersion, + StringComparison.Ordinal + ) + && video.Result.FpsNumerator > 0 + && itemsHere.Result.SceneItems?.Count + == serialHere.SceneItems?.Count; + + return ( + ok, + $"v={version.Result.ObsVersion}, fps={video.Result.FpsNumerator}, " + + $"items {itemsHere.Result.SceneItems?.Count} here vs " + + $"{itemsThere.Result.SceneItems?.Count} there" + ); + } + ) + .ConfigureAwait(false) + ); + + results.Add( + await TrySettingsCheckAsync( + "Low level Add and raw results", + async () => + { + // Hand rolled batch: Add covers anything the generated methods do not, + // and the raw payload helpers read it back. + ObsBatchBuilder raw = new(); + _ = raw.Add("GetVersion"); + _ = raw.Add("GetStats"); + + BatchResults rawResults = await client + .CallBatchAsync( + raw, + executionType: RequestBatchExecutionType.SerialRealtime, + cancellationToken: cancellationToken + ) + .ConfigureAwait(false); + + GetVersionResponseData? v = rawResults + .Raw[0] + .GetData(); + + // And the same request without any batch at all. + GetVersionResponseData? direct = await client + .CallAsync( + "GetVersion", + null, + cancellationToken: cancellationToken + ) + .ConfigureAwait(false); + + bool ok = + rawResults.Count == 2 + && v is not null + && direct is not null + && string.Equals( + v.ObsVersion, + direct.ObsVersion, + StringComparison.Ordinal + ); + + return ( + ok, + $"raw batch {rawResults.Count}, CallAsync {direct?.ObsVersion}" + ); + } + ) + .ConfigureAwait(false) + ); + + results.Add( + await TrySettingsCheckAsync( + "Scene item list reindexed", + async () => + { + // Reindexing asks OBS for the basic scene item list, a different shape + // from every other sceneItems array, so the event needs its own stub. + GetSceneItemListResponseData items = await client + .SceneItems.GetSceneItemListAsync( + new GetSceneItemListRequestData(sceneName: sceneName), + cancellationToken + ) + .ConfigureAwait(false); + if (items.SceneItems.Count == 0) + { + return (false, "no scene items to reindex"); + } + + long id = items.SceneItems[0].SceneItemId; + int index = items.SceneItems[0].SceneItemIndex; + + Task reindexed = + client.WaitForEventAsync( + timeout: TimeSpan.FromSeconds(5), + cancellationToken: cancellationToken + ); + + await client + .SceneItems.SetSceneItemIndexAsync( + new SetSceneItemIndexRequestData( + sceneItemId: id, + sceneItemIndex: index, + sceneName: sceneName + ), + cancellationToken + ) + .ConfigureAwait(false); + + SceneItemListReindexedEventArgs args = await reindexed.ConfigureAwait( + false + ); + + SceneItemOrderStub? moved = args.EventData.SceneItems.Find(i => + i.SceneItemId == id + ); + + return ( + moved is not null + && string.Equals( + args.EventData.SceneName, + sceneName, + StringComparison.Ordinal + ), + $"{args.EventData.SceneItems.Count} item(s) reindexed, " + + $"item {id} at index {moved?.SceneItemIndex}" + ); + } + ) + .ConfigureAwait(false) + ); + + results.Add( + await TrySettingsCheckAsync( + "Input volume meters", + async () => + { + // High rate event with its own stub. It was read as an InputStub and + // failed on the kind fields it never sends, so it never fired at all. + // A collection with no audio input reports no meters at all, which + // says nothing about whether the payload reads correctly. + GetInputListResponseData present = await client + .Inputs.GetInputListAsync(new(), cancellationToken) + .ConfigureAwait(false); + string? seeded = null; + if ( + !present.Inputs.Exists(i => + i.InputKind.Contains("wasapi", StringComparison.Ordinal) + ) + ) + { + // OBS meters only the inputs it considers active, which means + // the ones in the program scene. Anywhere else reports nothing. + GetSceneListResponseData live = await client + .Scenes.GetSceneListAsync(new(), cancellationToken) + .ConfigureAwait(false); + seeded = $"__obsws_meter_{Guid.NewGuid():N}"[..22]; + await client + .Inputs.CreateInputAsync( + new( + inputName: seeded, + inputKind: kinds.AudioCapture!, + sceneName: live.CurrentProgramSceneName ?? sceneName + ), + cancellationToken + ) + .ConfigureAwait(false); + } + + EventSubscription? before = client.CurrentEventSubscriptions; + await client + .ReidentifyAsync( + (uint)( + (before ?? EventSubscription.All) + | EventSubscription.InputVolumeMeters + ), + cancellationToken: cancellationToken + ) + .ConfigureAwait(false); + + try + { + InputVolumeMetersEventArgs meters = await client + .WaitForEventAsync( + timeout: TimeSpan.FromSeconds(5), + cancellationToken: cancellationToken + ) + .ConfigureAwait(false); + + InputVolumeMeterStub? first = + meters.EventData.Inputs.Count > 0 + ? meters.EventData.Inputs[0] + : null; + + // An input with no audio channels reports an empty level list, so + // the levels are checked where they exist rather than required. + bool levelsWellFormed = meters.EventData.Inputs.TrueForAll(i => + i.InputLevelsMul.TrueForAll(channel => channel.Count == 3) + ); + int channels = meters.EventData.Inputs.Sum(i => + i.InputLevelsMul.Count + ); + + bool ok = + first is not null + && !string.IsNullOrEmpty(first.InputName) + && Guid.TryParse(first.InputUuid, out _) + && levelsWellFormed; + + return ( + ok, + $"{meters.EventData.Inputs.Count} input(s), first '{first?.InputName}', " + + $"{channels} channel(s) total, three levels each = " + + $"{levelsWellFormed}" + ); + } + finally + { + if (before is not null) + { + await client + .ReidentifyAsync( + (uint)before.Value, + cancellationToken: CancellationToken.None + ) + .ConfigureAwait(false); + } + + if (seeded is not null) + { + await client + .Inputs.RemoveInputAsync( + new(inputName: seeded), + CancellationToken.None + ) + .ConfigureAwait(false); + } + } + } + ) + .ConfigureAwait(false) + ); + + results.Add( + await TrySettingsCheckAsync( + "Handles address by name and by uuid", + async () => + { + // The point of a uuid handle is that it survives a rename. Both forms + // have to reach the same scene for that to be worth anything. + SceneHandle byName = sceneName; + SceneOperations resolved = await client + .Scene(byName) + .ResolveAsync(cancellationToken) + .ConfigureAwait(false); + SceneHandle byUuid = resolved.Handle; + + GetSceneItemListResponseData viaName = await client + .Scene(byName) + .GetItemListAsync(cancellationToken) + .ConfigureAwait(false); + GetSceneItemListResponseData viaUuid = await client + .Scene(byUuid) + .GetItemListAsync(cancellationToken) + .ConfigureAwait(false); + + string renamed = sceneName + "_renamed"; + await client + .Scene(byUuid) + .SetNameAsync(renamed, cancellationToken) + .ConfigureAwait(false); + + bool uuidStillWorks; + try + { + _ = await client + .Scene(byUuid) + .GetItemListAsync(cancellationToken) + .ConfigureAwait(false); + uuidStillWorks = true; + } + catch (ObsWebSocketRequestException) + { + uuidStillWorks = false; + } + + bool nameNowMisses; + try + { + _ = await client + .Scene(byName) + .GetItemListAsync(cancellationToken) + .ConfigureAwait(false); + nameNowMisses = false; + } + catch (ObsWebSocketRequestException) + { + nameNowMisses = true; + } + + await client + .Scene(byUuid) + .SetNameAsync(sceneName, CancellationToken.None) + .ConfigureAwait(false); + + return ( + byUuid.IsResolved + && viaName.SceneItems.Count == viaUuid.SceneItems.Count + && uuidStillWorks + && nameNowMisses, + $"resolved to {byUuid.Uuid}, both read {viaUuid.SceneItems.Count} " + + $"item(s); after a rename the uuid still resolves " + + $"({uuidStillWorks}) and the name does not ({nameNowMisses})" + ); + } + ) + .ConfigureAwait(false) + ); + + results.Add( + await TrySettingsCheckAsync( + "A miss says what does exist", + async () => + { + // OBS answers ResourceNotFound and the name you already gave it. The + // list is in hand from the lookup, so the client can do better. + ObsWebSocketResourceNotFoundException ex = + await ExpectThrowAsync(() => + client + .Scenes.ResolveAsync( + "__obsws_no_such_scene", + cancellationToken + ) + .AsTask() + ) + .ConfigureAwait(false); + + return ( + ex.Available.Count > 0 + && ex.Message.Contains( + "__obsws_no_such_scene", + StringComparison.Ordinal + ) + && ex.Available.Contains(sceneName, StringComparer.Ordinal), + $"named {ex.Available.Count} scene(s), including the one the run made" + ); + } + ) + .ConfigureAwait(false) + ); + + results.Add( + await TrySettingsCheckAsync( + "A scene item resolves by source name", + async () => + { + // The one lookup that is not a convenience: OBS addresses scene items + // by a number nothing else reports. + SceneItemOperations item = await client + .Scene(sceneName) + .ItemAsync(inputName, cancellationToken: cancellationToken) + .ConfigureAwait(false); + + GetSceneItemEnabledResponseData enabled = await item.GetEnabledAsync( + cancellationToken + ) + .ConfigureAwait(false); + + // Navigating back up reaches the scene the item is in. + GetSceneItemListResponseData siblings = await item + .Scene.GetItemListAsync(cancellationToken) + .ConfigureAwait(false); + + return ( + item.Handle.SceneItemId >= 0 && siblings.SceneItems.Count > 0, + $"'{inputName}' is item {item.Handle.SceneItemId}, enabled " + + $"{enabled.SceneItemEnabled}, among {siblings.SceneItems.Count} " + + "in its scene" + ); + } + ) + .ConfigureAwait(false) + ); + + results.Add( + await TrySettingsCheckAsync( + "Canvases category", + async () => + { + // The only request in its category. Its array carries no item type in + // the protocol definition, so the stub is taken from the request + // handler and has to be checked against a real OBS on both transports. + GetCanvasListResponseData canvases = await client + .Canvases.GetCanvasListAsync(cancellationToken) + .ConfigureAwait(false); + + CanvasStub? main = canvases.Canvases.Find(c => c.CanvasFlags.Main); + bool ok = + canvases.Canvases.Count > 0 + && main is not null + && !string.IsNullOrEmpty(main.CanvasName) + && Guid.TryParse(main.CanvasUuid, out _) + && main.CanvasVideoSettings.BaseWidth > 0 + && main.CanvasVideoSettings.BaseHeight > 0 + && main.CanvasVideoSettings.FpsNumerator > 0; + + return ( + ok, + $"{canvases.Canvases.Count} canvas(es), main '{main?.CanvasName}' " + + $"{main?.CanvasVideoSettings.BaseWidth}x{main?.CanvasVideoSettings.BaseHeight} " + + $"@ {main?.CanvasVideoSettings.FpsNumerator}/{main?.CanvasVideoSettings.FpsDenominator}, " + + $"flags MAIN={main?.CanvasFlags.Main} MIX_AUDIO={main?.CanvasFlags.MixAudio}" + ); + } + ) + .ConfigureAwait(false) + ); + + results.Add( + await TrySettingsCheckAsync( + "Request with neither payload", + async () => + { + // The one generated shape nothing else exercises: no request data and + // no response. Pointing Preview at the scene already in Program makes + // the transition a no-op, so the check is safe to run. + GetStudioModeEnabledResponseData studio = await client + .Ui.GetStudioModeEnabledAsync(cancellationToken) + .ConfigureAwait(false); + if (!studio.StudioModeEnabled) + { + await client + .Ui.SetStudioModeEnabledAsync(new(true), cancellationToken) + .ConfigureAwait(false); + } + + try + { + GetSceneListResponseData before = await client + .Scenes.GetSceneListAsync(new(), cancellationToken) + .ConfigureAwait(false); + string program = before.CurrentProgramSceneName!; + + // Plain switch, not the waiting variant: OBS raises no + // CurrentPreviewSceneChanged when the preview is already that + // scene, which enabling Studio Mode has just made it. + await client + .Scenes.SwitchPreviewSceneAsync(program, cancellationToken) + .ConfigureAwait(false); + + await client + .Transitions.TriggerStudioModeTransitionAsync(cancellationToken) + .ConfigureAwait(false); + + GetSceneListResponseData after = await client + .Scenes.GetSceneListAsync(new(), cancellationToken) + .ConfigureAwait(false); + + return ( + string.Equals( + after.CurrentProgramSceneName, + program, + StringComparison.Ordinal + ), + $"transitioned, program still '{after.CurrentProgramSceneName}'" + ); + } + finally + { + if (!studio.StudioModeEnabled) + { + await client + .Ui.SetStudioModeEnabledAsync(new(false), cancellationToken) + .ConfigureAwait(false); + } + } + } + ) + .ConfigureAwait(false) + ); + + results.Add( + await TrySettingsCheckAsync( + "Nested request object", + async () => + { + // keyModifiers is the one generated nested record, a distinct shape + // from the flat request payloads. F13 is not bound by default, so the + // press does nothing. + await client + .General.TriggerHotkeyByKeySequenceAsync( + new TriggerHotkeyByKeySequenceRequestData( + keyId: "OBS_KEY_F13", + keyModifiers: new TriggerHotkeyByKeySequenceRequestData_KeyModifiers( + shift: false, + control: true, + alt: false, + command: false + ) + ), + cancellationToken + ) + .ConfigureAwait(false); + + return (true, "nested keyModifiers accepted"); + } + ) + .ConfigureAwait(false) + ); + + results.Add( + await TrySettingsCheckAsync( + "Remaining stub types", + async () => + { + // Monitor, output and transition stubs are generated the same way as + // the scene and input ones, so one read each covers the shape. + GetMonitorListResponseData monitors = await client + .Ui.GetMonitorListAsync(cancellationToken) + .ConfigureAwait(false); + GetSceneTransitionListResponseData transitions = await client + .Transitions.GetSceneTransitionListAsync(cancellationToken) + .ConfigureAwait(false); + bool ok = + monitors.Monitors.Count > 0 + && transitions.Transitions.Count > 0 + && outputs.Outputs.Count > 0 + && monitors.Monitors[0].MonitorWidth > 0 + && !string.IsNullOrEmpty(transitions.Transitions[0].TransitionName) + && !string.IsNullOrEmpty(outputs.Outputs[0].OutputName); + + return ( + ok, + $"{monitors.Monitors.Count} monitor(s) first {monitors.Monitors[0].MonitorWidth}px, " + + $"{transitions.Transitions.Count} transition(s), {outputs.Outputs.Count} output(s)" + ); + } + ) + .ConfigureAwait(false) + ); + + results.Add( + await TrySettingsCheckAsync( + "Classic handler and subscriptions", + async () => + { + // The stream path is covered above; this is the += path, plus the + // negotiated subscription flags the client reports. + TaskCompletionSource seen = new( + TaskCreationOptions.RunContinuationsAsynchronously + ); + string? observed = null; + void Handler(object? sender, SceneCreatedEventArgs e) + { + observed = e.EventData.SceneName; + _ = seen.TrySetResult(); + } + + client.Scenes.SceneCreated += Handler; + string probe = $"__obsws_handler_{Guid.NewGuid():N}"[..24]; + try + { + await client + .Scenes.CreateSceneAsync( + new CreateSceneRequestData(sceneName: probe), + cancellationToken + ) + .ConfigureAwait(false); + await seen + .Task.WaitAsync(TimeSpan.FromSeconds(5), cancellationToken) + .ConfigureAwait(false); + } + finally + { + client.Scenes.SceneCreated -= Handler; + await client + .Scenes.RemoveSceneAsync( + new RemoveSceneRequestData(sceneName: probe), + CancellationToken.None + ) + .ConfigureAwait(false); + } + + EventSubscription? subs = client.CurrentEventSubscriptions; + + return ( + string.Equals(observed, probe, StringComparison.Ordinal) + && subs is not null + && subs.Value.HasFlag(EventSubscription.Scenes), + $"handler saw '{observed}', subscriptions {subs}" + ); + } + ) + .ConfigureAwait(false) + ); + + results.Add( + await TrySettingsCheckAsync( + "Every way of sending a request", + async () => + { + // One request, reached six ways, so no path is left unexercised. + // 1. The generated request on its category group. + GetVersionResponseData viaGroup = await client + .General.GetVersionAsync(cancellationToken) + .ConfigureAwait(false); + + // 2. The low level typed call, for a reference type response. + GetVersionResponseData? viaCall = await client + .CallAsync( + "GetVersion", + null, + cancellationToken: cancellationToken + ) + .ConfigureAwait(false); + + // 3. The low level untyped call, for a value type response. + JsonElement? viaValue = await client + .CallAsyncValue( + "GetVersion", + null, + cancellationToken: cancellationToken + ) + .ConfigureAwait(false); + string? viaRawJson = viaValue?.GetProperty("obsVersion").GetString(); + + // 4. A hand built JsonElement as the request payload. + using JsonDocument requestBody = JsonDocument.Parse( + $$"""{"sceneName":"{{sceneName}}"}""" + ); + JsonElement? viaJsonBody = await client + .CallAsyncValue( + "GetSceneItemList", + requestBody.RootElement, + cancellationToken: cancellationToken + ) + .ConfigureAwait(false); + int itemsViaJsonBody = + viaJsonBody?.GetProperty("sceneItems").GetArrayLength() ?? -1; + + // 5. The typed batch builder. + ObsBatchBuilder builder = new(); + BatchRef batched = builder.General.GetVersion(); + BatchResults built = await client + .CallBatchAsync( + builder, + executionType: RequestBatchExecutionType.SerialRealtime, + cancellationToken: cancellationToken + ) + .ConfigureAwait(false); + string viaBatch = built.Get(batched).ObsVersion; + + // 6. A hand rolled batch item, with a JsonElement payload. + using JsonDocument batchBody = JsonDocument.Parse( + $$"""{"sceneName":"{{sceneName}}"}""" + ); + List> viaRawBatch = await client + .CallBatchAsync( + [ + new BatchRequestItem("GetVersion", null), + new BatchRequestItem( + "GetSceneItemList", + batchBody.RootElement + ), + ], + executionType: RequestBatchExecutionType.SerialRealtime, + cancellationToken: cancellationToken + ) + .ConfigureAwait(false); + string? viaRawBatchVersion = viaRawBatch[0] + .GetData() + ?.ObsVersion; + + // 7. A consumer's own JsonSerializerContext, so a type this library + // has never heard of is sent without hand building a JsonElement. + JsonElement? viaConsumerContext = await client + .CallAsyncValue( + "GetSceneItemList", + new ConsumerSceneRequest(sceneName), + ExampleRequestContext.Default.ConsumerSceneRequest, + cancellationToken: cancellationToken + ) + .ConfigureAwait(false); + int itemsViaContext = + viaConsumerContext?.GetProperty("sceneItems").GetArrayLength() + ?? -1; + + // And the one shape that is not supported, asserted as unsupported: + // an anonymous object has no metadata in the serializer context. + string anonymous; + try + { + _ = await client + .CallAsyncValue( + "GetSceneItemList", + new { sceneName }, + cancellationToken: cancellationToken + ) + .ConfigureAwait(false); + anonymous = "unexpectedly accepted"; + } + catch (ObsWebSocketSerializationException) + { + anonymous = "refused"; + } + + string expected = viaGroup.ObsVersion; + bool allAgree = + viaCall?.ObsVersion == expected + && viaRawJson == expected + && viaBatch == expected + && viaRawBatchVersion == expected + && itemsViaJsonBody >= 0 + && itemsViaContext == itemsViaJsonBody + && anonymous == "refused"; + + return ( + allAgree, + $"seven paths agree on {expected}, JsonElement body and consumer " + + $"context both read {itemsViaJsonBody} item(s), anonymous " + + $"object {anonymous}" + ); + } + ) + .ConfigureAwait(false) + ); + + results.Add( + await TrySettingsCheckAsync( + "Typed exception on a rejected request", + async () => + { + try + { + _ = await client + .SceneItems.GetSceneItemListAsync( + new GetSceneItemListRequestData( + sceneName: "__no_such_scene__" + ), + cancellationToken + ) + .ConfigureAwait(false); + return (false, "no exception"); + } + catch (ObsWebSocketRequestException ex) + { + return ( + ex.StatusCode == RequestStatusCode.ResourceNotFound + && ex.RequestType == "GetSceneItemList", + $"{ex.RequestType} code {(int?)ex.StatusCode}" + ); + } + } + ) + .ConfigureAwait(false) + ); + + results.Add( + await TrySettingsCheckAsync( + "Output state helpers", + async () => + { + bool recording = await client + .Record.IsRecordActiveAsync(cancellationToken) + .ConfigureAwait(false); + bool streaming = await client + .Stream.IsStreamActiveAsync(cancellationToken) + .ConfigureAwait(false); + bool virtualCam = await client + .Outputs.IsVirtualCamActiveAsync(cancellationToken) + .ConfigureAwait(false); + + // Each helper has to agree with the request it wraps. + GetRecordStatusResponseData? recordStatus = await client + .Record.GetRecordStatusAsync(cancellationToken) + .ConfigureAwait(false); + GetStreamStatusResponseData? streamStatus = await client + .Stream.GetStreamStatusAsync(cancellationToken) + .ConfigureAwait(false); + GetVirtualCamStatusResponseData? camStatus = await client + .Outputs.GetVirtualCamStatusAsync(cancellationToken) + .ConfigureAwait(false); + + bool agrees = + recording == recordStatus?.OutputActive + && streaming == streamStatus?.OutputActive + && virtualCam == camStatus?.OutputActive; + + return ( + agrees, + $"record={recording}, stream={streaming}, virtualCam={virtualCam}, agrees={agrees}" + ); + } + ) + .ConfigureAwait(false) + ); + } + finally + { + // Always put OBS back the way it was found. + try + { + if (!string.IsNullOrEmpty(originalScene)) + { + await client + .Scenes.SwitchProgramSceneAsync( + originalScene, + cancellationToken: CancellationToken.None + ) + .ConfigureAwait(false); + } + + if (inputCreated) + { + await client + .Inputs.RemoveInputAsync( + new RemoveInputRequestData(inputName: inputName), + CancellationToken.None + ) + .ConfigureAwait(false); + } + + if (sceneCreated) + { + await client + .Scenes.RemoveSceneAsync( + new RemoveSceneRequestData(sceneName: sceneName), + CancellationToken.None + ) + .ConfigureAwait(false); + } + } + catch (ObsWebSocketException ex) + { + results.Add(("Cleanup", false, ex.Message)); + } + } + + return results; + } + + internal static async Task<(string Label, bool Pass, string Detail)> TrySettingsCheckAsync( + string label, + Func> action + ) + { + try + { + (bool pass, string detail) = await action().ConfigureAwait(false); + return (label, pass, detail); + } + catch (Exception ex) + { + string msg = ex.Message.Length > 100 ? ex.Message[..100] : ex.Message; + return (label, false, msg); + } + } + + /// + /// Calls every read-only request in the protocol and reports the ones whose response could not + /// be deserialized. + /// + /// + /// The targeted checks elsewhere cover behaviour; this covers surface. A response record that + /// does not match what OBS sends is invisible until something reads it, and three of them were + /// shipping. Requests OBS declines for the state of the machine (no replay buffer, no group, + /// an input of the wrong kind) are reported as untested rather than as failures, so the count + /// says how much of the surface was actually exercised. + /// + /// + /// Waits for OBS to apply a profile change, which it does after answering the request. + /// + internal static async Task WaitForProfileAsync( + ObsWebSocketClient client, + Func applied, + CancellationToken cancellationToken + ) + { + for (int attempt = 0; attempt < 50; attempt++) + { + GetProfileListResponseData profiles = await client + .Config.GetProfileListAsync(cancellationToken) + .ConfigureAwait(false); + if (applied(profiles)) + { + return true; + } + + await Task.Delay(100, cancellationToken).ConfigureAwait(false); + } + + return false; + } + + /// + /// Settings for the fixtures' media source, pointing at the clip shipped next to the binary. + /// + /// + /// A media source with no file is never playing, and OBS answers the cursor requests with + /// 604, so they are sent but never exercised. + /// + internal static JsonElement MediaFixtureSettings() + { + ArrayBufferWriter buffer = new(); + using (Utf8JsonWriter writer = new(buffer)) + { + writer.WriteStartObject(); + writer.WriteString( + "local_file", + Path.Combine(AppContext.BaseDirectory, "fixtures", "media-fixture.mp4") + ); + writer.WriteBoolean("is_local_file", true); + writer.WriteBoolean("looping", true); + writer.WriteEndObject(); + writer.Flush(); + } + + using JsonDocument document = JsonDocument.Parse(buffer.WrittenMemory); + return document.RootElement.Clone(); + } + + internal static async Task< + List<(string Label, bool Pass, string Detail)> + > SweepEveryReadRequestAsync( + ObsWebSocketClient client, + ObsSourceKinds kinds, + GetOutputListResponseData outputs, + CancellationToken cancellationToken + ) + { + List unreadable = []; + List untested = []; + HashSet read = new(StringComparer.Ordinal); + + async Task Probe(string name, Func call) + { + try + { + await call().ConfigureAwait(false); + _ = read.Add(name); + } + catch (ObsWebSocketSerializationException ex) + { + unreadable.Add($"{name}: {ex.InnerException?.Message ?? ex.Message}"); + } + catch (ObsWebSocketRequestException ex) + { + // OBS declined for the state of the machine, so the response shape was never + // exercised. Not a defect, but not coverage either. + untested.Add($"{name} ({ex.StatusCode})"); + } + } + + // Discover targets, so the sweep needs no fixture of its own. + GetSceneListResponseData scenes = await client + .Scenes.GetSceneListAsync(new(), cancellationToken) + .ConfigureAwait(false); + string sceneName = scenes.CurrentProgramSceneName ?? scenes.Scenes[0].SceneName; + + GetInputListResponseData inputs = await client + .Inputs.GetInputListAsync(new(), cancellationToken) + .ConfigureAwait(false); + string inputName = inputs.Inputs[0].InputName; + + GetInputKindListResponseData inputKinds = await client + .Inputs.GetInputKindListAsync(new(), cancellationToken) + .ConfigureAwait(false); + string inputKind = inputKinds.InputKinds[0]; + + GetSourceFilterKindListResponseData filterKinds = await client + .Filters.GetSourceFilterKindListAsync(cancellationToken) + .ConfigureAwait(false); + string filterKind = filterKinds.SourceFilterKinds[0]; + + string outputName = outputs.Outputs[0].OutputName; + + GetGroupListResponseData groups = await client + .Scenes.GetGroupListAsync(cancellationToken) + .ConfigureAwait(false); + string? groupName = groups.Groups.Count > 0 ? groups.Groups[0] : null; + + // The discovery calls above are read requests too, and naming them here rather than + // counting them keeps the total honest when the discovery changes. + read.UnionWith([ + "GetSceneList", + "GetInputList", + "GetSceneItemList", + "GetInputKindList", + "GetSourceFilterKindList", + "GetOutputList", + "GetGroupList", + "GetStudioModeEnabled", + ]); + + // Four of these requests need OBS to be in a particular state, not just to be asked + // nicely. Without the fixture they answer InvalidResourceState and the response shape is + // never exercised, which is coverage the report would otherwise claim. + string fixtureSuffix = Guid.NewGuid().ToString("N")[..8]; + string readScene = $"__obsws_rsweep_{fixtureSuffix}"; + string audioInput = $"__obsws_rsweep_audio_{fixtureSuffix}"; + string mediaInput = $"__obsws_rsweep_media_{fixtureSuffix}"; + + GetStudioModeEnabledResponseData studioBefore = await client + .Ui.GetStudioModeEnabledAsync(cancellationToken) + .ConfigureAwait(false); + + await client + .Scenes.CreateSceneAsync(new(sceneName: readScene), cancellationToken) + .ConfigureAwait(false); + await client + .Inputs.CreateInputAsync( + new(inputName: audioInput, inputKind: kinds.AudioCapture!, sceneName: readScene), + cancellationToken + ) + .ConfigureAwait(false); + await client + .Inputs.CreateInputAsync( + new( + inputName: mediaInput, + inputKind: "ffmpeg_source", + inputSettings: MediaFixtureSettings(), + sceneName: readScene + ), + cancellationToken + ) + .ConfigureAwait(false); + await client + .Filters.CreateSourceFilterAsync( + new( + filterName: FixtureFilterName, + filterKind: "color_filter_v2", + sourceName: mediaInput + ), + cancellationToken + ) + .ConfigureAwait(false); + if (!studioBefore.StudioModeEnabled) + { + await client + .Ui.SetStudioModeEnabledAsync(new(true), cancellationToken) + .ConfigureAwait(false); + } + + // From the fixture, not from the program scene: a fresh OBS profile opens on an empty + // scene, and the seven scene item requests then go unexercised. + GetSceneItemListResponseData fixtureItems = await client + .SceneItems.GetSceneItemListAsync(new(sceneName: readScene), cancellationToken) + .ConfigureAwait(false); + long sceneItemId = fixtureItems.SceneItems[0].SceneItemId; + string itemSourceName = fixtureItems.SceneItems[0].SourceName; + + try + { + await Probe( + "GetCanvasList", + () => client.Canvases.GetCanvasListAsync(cancellationToken) + ) + .ConfigureAwait(false); + + await Probe( + "GetPersistentData", + () => + client.Config.GetPersistentDataAsync( + new( + realm: "OBS_WEBSOCKET_DATA_REALM_PROFILE", + slotName: "__obsws_sweep" + ), + cancellationToken + ) + ) + .ConfigureAwait(false); + await Probe( + "GetSceneCollectionList", + () => client.Config.GetSceneCollectionListAsync(cancellationToken) + ) + .ConfigureAwait(false); + await Probe( + "GetProfileList", + () => client.Config.GetProfileListAsync(cancellationToken) + ) + .ConfigureAwait(false); + await Probe( + "GetProfileParameter", + () => + client.Config.GetProfileParameterAsync( + new(parameterCategory: "General", parameterName: "Name"), + cancellationToken + ) + ) + .ConfigureAwait(false); + await Probe( + "GetVideoSettings", + () => client.Config.GetVideoSettingsAsync(cancellationToken) + ) + .ConfigureAwait(false); + await Probe( + "GetStreamServiceSettings", + () => client.Config.GetStreamServiceSettingsAsync(cancellationToken) + ) + .ConfigureAwait(false); + await Probe( + "GetRecordDirectory", + () => client.Config.GetRecordDirectoryAsync(cancellationToken) + ) + .ConfigureAwait(false); + + await Probe( + "GetSourceFilterDefaultSettings", + () => + client.Filters.GetSourceFilterDefaultSettingsAsync( + new(filterKind: filterKind), + cancellationToken + ) + ) + .ConfigureAwait(false); + await Probe( + "GetSourceFilterList", + () => + client.Filters.GetSourceFilterListAsync( + new(sourceName: mediaInput), + cancellationToken + ) + ) + .ConfigureAwait(false); + await Probe( + "GetSourceFilter", + () => + client.Filters.GetSourceFilterAsync( + new(filterName: FixtureFilterName, sourceName: mediaInput), + cancellationToken + ) + ) + .ConfigureAwait(false); + + await Probe("GetVersion", () => client.General.GetVersionAsync(cancellationToken)) + .ConfigureAwait(false); + await Probe("GetStats", () => client.General.GetStatsAsync(cancellationToken)) + .ConfigureAwait(false); + await Probe("GetHotkeyList", () => client.General.GetHotkeyListAsync(cancellationToken)) + .ConfigureAwait(false); + + await Probe( + "GetSpecialInputs", + () => client.Inputs.GetSpecialInputsAsync(cancellationToken) + ) + .ConfigureAwait(false); + await Probe( + "GetInputDefaultSettings", + () => + client.Inputs.GetInputDefaultSettingsAsync( + new(inputKind: inputKind), + cancellationToken + ) + ) + .ConfigureAwait(false); + await Probe( + "GetInputSettings", + () => + client.Inputs.GetInputSettingsAsync( + new(inputName: inputName), + cancellationToken + ) + ) + .ConfigureAwait(false); + await Probe( + "GetInputMute", + () => + client.Inputs.GetInputMuteAsync( + new(inputName: audioInput), + cancellationToken + ) + ) + .ConfigureAwait(false); + await Probe( + "GetInputVolume", + () => + client.Inputs.GetInputVolumeAsync( + new(inputName: audioInput), + cancellationToken + ) + ) + .ConfigureAwait(false); + await Probe( + "GetInputAudioBalance", + () => + client.Inputs.GetInputAudioBalanceAsync( + new(inputName: audioInput), + cancellationToken + ) + ) + .ConfigureAwait(false); + await Probe( + "GetInputAudioSyncOffset", + () => + client.Inputs.GetInputAudioSyncOffsetAsync( + new(inputName: audioInput), + cancellationToken + ) + ) + .ConfigureAwait(false); + await Probe( + "GetInputAudioMonitorType", + () => + client.Inputs.GetInputAudioMonitorTypeAsync( + new(inputName: audioInput), + cancellationToken + ) + ) + .ConfigureAwait(false); + await Probe( + "GetInputAudioTracks", + () => + client.Inputs.GetInputAudioTracksAsync( + new(inputName: audioInput), + cancellationToken + ) + ) + .ConfigureAwait(false); + await Probe( + "GetInputDeinterlaceMode", + () => + client.Inputs.GetInputDeinterlaceModeAsync( + new(inputName: mediaInput), + cancellationToken + ) + ) + .ConfigureAwait(false); + await Probe( + "GetInputDeinterlaceFieldOrder", + () => + client.Inputs.GetInputDeinterlaceFieldOrderAsync( + new(inputName: mediaInput), + cancellationToken + ) + ) + .ConfigureAwait(false); + await Probe( + "GetInputPropertiesListPropertyItems", + () => + client.Inputs.GetInputPropertiesListPropertyItemsAsync( + new(propertyName: "device_id", inputName: audioInput), + cancellationToken + ) + ) + .ConfigureAwait(false); + + await Probe( + "GetMediaInputStatus", + () => + client.MediaInputs.GetMediaInputStatusAsync( + new(inputName: mediaInput), + cancellationToken + ) + ) + .ConfigureAwait(false); + + await Probe( + "GetVirtualCamStatus", + () => client.Outputs.GetVirtualCamStatusAsync(cancellationToken) + ) + .ConfigureAwait(false); + await Probe( + "GetReplayBufferStatus", + () => client.Outputs.GetReplayBufferStatusAsync(cancellationToken) + ) + .ConfigureAwait(false); + // GetLastReplayBufferReplay is left untested rather than prepared for. Starting and + // stopping the replay buffer to save a clip crashed OBS: the dump lands in + // GetOutputList, on obs_encoder_get_width against the encoder the buffer had just + // freed. Enumerating outputs while one is being torn down is not something a client + // can make safe, and this sweep is not worth an OBS restart per run. + await Probe( + "GetLastReplayBufferReplay", + () => client.Outputs.GetLastReplayBufferReplayAsync(cancellationToken) + ) + .ConfigureAwait(false); + + await Probe( + "GetOutputStatus", + () => + client.Outputs.GetOutputStatusAsync( + new(outputName: outputName), + cancellationToken + ) + ) + .ConfigureAwait(false); + await Probe( + "GetOutputSettings", + () => + client.Outputs.GetOutputSettingsAsync( + new(outputName: outputName), + cancellationToken + ) + ) + .ConfigureAwait(false); + + await Probe( + "GetRecordStatus", + () => client.Record.GetRecordStatusAsync(cancellationToken) + ) + .ConfigureAwait(false); + + if (groupName is not null) + { + await Probe( + "GetGroupSceneItemList", + () => + client.SceneItems.GetGroupSceneItemListAsync( + new(sceneName: groupName), + cancellationToken + ) + ) + .ConfigureAwait(false); + } + else + { + // There is no CreateGroup request in the protocol, so a group can only come from a + // scene collection that already has one. + untested.Add("GetGroupSceneItemList (no group; the protocol cannot create one)"); + } + + await Probe( + "GetSceneItemId", + () => + client.SceneItems.GetSceneItemIdAsync( + new(sourceName: itemSourceName, sceneName: readScene), + cancellationToken + ) + ) + .ConfigureAwait(false); + await Probe( + "GetSceneItemSource", + () => + client.SceneItems.GetSceneItemSourceAsync( + new(sceneItemId: sceneItemId, sceneName: readScene), + cancellationToken + ) + ) + .ConfigureAwait(false); + await Probe( + "GetSceneItemTransform", + () => + client.SceneItems.GetSceneItemTransformAsync( + new(sceneItemId: sceneItemId, sceneName: readScene), + cancellationToken + ) + ) + .ConfigureAwait(false); + await Probe( + "GetSceneItemEnabled", + () => + client.SceneItems.GetSceneItemEnabledAsync( + new(sceneItemId: sceneItemId, sceneName: readScene), + cancellationToken + ) + ) + .ConfigureAwait(false); + await Probe( + "GetSceneItemLocked", + () => + client.SceneItems.GetSceneItemLockedAsync( + new(sceneItemId: sceneItemId, sceneName: readScene), + cancellationToken + ) + ) + .ConfigureAwait(false); + await Probe( + "GetSceneItemIndex", + () => + client.SceneItems.GetSceneItemIndexAsync( + new(sceneItemId: sceneItemId, sceneName: readScene), + cancellationToken + ) + ) + .ConfigureAwait(false); + await Probe( + "GetSceneItemBlendMode", + () => + client.SceneItems.GetSceneItemBlendModeAsync( + new(sceneItemId: sceneItemId, sceneName: readScene), + cancellationToken + ) + ) + .ConfigureAwait(false); + + await Probe( + "GetCurrentProgramScene", + () => client.Scenes.GetCurrentProgramSceneAsync(cancellationToken) + ) + .ConfigureAwait(false); + await Probe( + "GetCurrentPreviewScene", + () => client.Scenes.GetCurrentPreviewSceneAsync(cancellationToken) + ) + .ConfigureAwait(false); + await Probe( + "GetSceneSceneTransitionOverride", + () => + client.Scenes.GetSceneSceneTransitionOverrideAsync( + new(sceneName: sceneName), + cancellationToken + ) + ) + .ConfigureAwait(false); + + await Probe( + "GetSourceActive", + () => + client.Sources.GetSourceActiveAsync( + new(sourceName: inputName), + cancellationToken + ) + ) + .ConfigureAwait(false); + await Probe( + "GetSourceScreenshot", + () => + client.Sources.GetSourceScreenshotAsync( + new(imageFormat: "png", sourceName: sceneName), + cancellationToken + ) + ) + .ConfigureAwait(false); + + await Probe( + "GetStreamStatus", + () => client.Stream.GetStreamStatusAsync(cancellationToken) + ) + .ConfigureAwait(false); + + await Probe( + "GetTransitionKindList", + () => client.Transitions.GetTransitionKindListAsync(cancellationToken) + ) + .ConfigureAwait(false); + await Probe( + "GetSceneTransitionList", + () => client.Transitions.GetSceneTransitionListAsync(cancellationToken) + ) + .ConfigureAwait(false); + await Probe( + "GetCurrentSceneTransition", + () => client.Transitions.GetCurrentSceneTransitionAsync(cancellationToken) + ) + .ConfigureAwait(false); + await Probe( + "GetCurrentSceneTransitionCursor", + () => client.Transitions.GetCurrentSceneTransitionCursorAsync(cancellationToken) + ) + .ConfigureAwait(false); + + await Probe( + "GetStudioModeEnabled", + () => client.Ui.GetStudioModeEnabledAsync(cancellationToken) + ) + .ConfigureAwait(false); + await Probe("GetMonitorList", () => client.Ui.GetMonitorListAsync(cancellationToken)) + .ConfigureAwait(false); + } + finally + { + if (!studioBefore.StudioModeEnabled) + { + await client + .Ui.SetStudioModeEnabledAsync(new(false), CancellationToken.None) + .ConfigureAwait(false); + } + + foreach (string fixture in new[] { audioInput, mediaInput }) + { + await client + .Inputs.RemoveInputAsync(new(inputName: fixture), CancellationToken.None) + .ConfigureAwait(false); + } + + await client + .Scenes.RemoveSceneAsync(new(sceneName: readScene), CancellationToken.None) + .ConfigureAwait(false); + } + + return + [ + ( + "Every read request deserializes", + unreadable.Count == 0 && read.Count + untested.Count >= 60, + unreadable.Count > 0 ? string.Join(" | ", unreadable.Take(3)) + : read.Count + untested.Count < 60 + ? $"only {read.Count + untested.Count} of 60 accounted for; a probe is missing" + : untested.Count == 0 ? $"all {read.Count} of 60 read" + : $"{read.Count} of 60 read; untested: {string.Join(", ", untested)}" + ), + ]; + } + + /// + /// Sends every write request that can be exercised without taking the machine somewhere it + /// cannot come back from, and reports the ones that could not be serialized. + /// + /// + /// Most write requests answer with no payload, so what this covers is the request side. That + /// is not a formality: SetInputAudioTracks could not be sent over MessagePack at all, + /// for the same missing formatter that made GetInputAudioTracks unreadable. + /// + /// Everything runs against a scene, an input and a filter this method creates and removes. + /// Requests that can only touch global state read the current value and write it back, so the + /// call is real and the setting is unchanged. + /// + /// + /// Deliberately not sent, because the cost of running them is not a shape bug: anything that + /// starts a stream, a recording, the replay buffer or the virtual camera; profile and scene + /// collection switching, which reloads OBS underneath the run; the dialog and projector + /// requests, which open windows; TriggerHotkeyByName and + /// PressInputPropertiesButton, which do whatever the target happens to do; + /// CallVendorRequest, which needs a plugin; and Sleep, which is batch only. + /// + /// + internal static async Task< + List<(string Label, bool Pass, string Detail)> + > SweepEveryWriteRequestAsync( + ObsWebSocketClient client, + ObsSourceKinds kinds, + GetOutputListResponseData outputs, + CancellationToken cancellationToken + ) + { + List unsendable = []; + List declined = []; + int sent = 0; + + async Task Probe(string name, Func call) + { + try + { + await call().ConfigureAwait(false); + sent++; + } + catch (ObsWebSocketSerializationException ex) + { + unsendable.Add($"{name}: {ex.InnerException?.Message ?? ex.Message}"); + } + catch (ObsWebSocketRequestException ex) + { + declined.Add($"{name} ({ex.StatusCode})"); + } + } + + string suffix = Guid.NewGuid().ToString("N")[..8]; + string sceneName = $"__obsws_wsweep_{suffix}"; + string renamedScene = $"{sceneName}_r"; + string inputName = $"__obsws_wsweep_in_{suffix}"; + string renamedInput = $"{inputName}_r"; + string filterName = "__obsws_wsweep_filter"; + string renamedFilter = $"{filterName}_r"; + + GetSceneListResponseData scenesBefore = await client + .Scenes.GetSceneListAsync(new(), cancellationToken) + .ConfigureAwait(false); + string originalProgramScene = scenesBefore.CurrentProgramSceneName!; + + string outputName = outputs.Outputs[0].OutputName; + + // ── Fixture ────────────────────────────────────────────────────────── + await Probe( + "CreateScene", + () => client.Scenes.CreateSceneAsync(new(sceneName: sceneName), cancellationToken) + ) + .ConfigureAwait(false); + await Probe( + "CreateInput", + () => + client.Inputs.CreateInputAsync( + new( + inputName: inputName, + inputKind: "color_source_v3", + sceneName: sceneName + ), + cancellationToken + ) + ) + .ConfigureAwait(false); + + // A colour source has no audio and cannot be deinterlaced. Pointing every audio and media + // request at one is how six read requests came back declined rather than exercised. + string audioInput = $"__obsws_wsweep_audio_{suffix}"; + string mediaInput = $"__obsws_wsweep_media_{suffix}"; + await Probe( + "CreateInput (audio)", + () => + client.Inputs.CreateInputAsync( + new( + inputName: audioInput, + inputKind: kinds.AudioCapture!, + sceneName: sceneName + ), + cancellationToken + ) + ) + .ConfigureAwait(false); + await Probe( + "CreateInput (media)", + () => + client.Inputs.CreateInputAsync( + new( + inputName: mediaInput, + inputKind: "ffmpeg_source", + inputSettings: MediaFixtureSettings(), + sceneName: sceneName + ), + cancellationToken + ) + ) + .ConfigureAwait(false); + + try + { + GetSceneItemListResponseData fixtureItems = await client + .SceneItems.GetSceneItemListAsync(new(sceneName: sceneName), cancellationToken) + .ConfigureAwait(false); + long itemId = fixtureItems.SceneItems[0].SceneItemId; + + // ── Scenes ─────────────────────────────────────────────────────── + await Probe( + "SetCurrentProgramScene", + () => + client.Scenes.SetCurrentProgramSceneAsync( + new(sceneName: sceneName), + cancellationToken + ) + ) + .ConfigureAwait(false); + await Probe( + "SetSceneSceneTransitionOverride", + () => + client.Scenes.SetSceneSceneTransitionOverrideAsync( + new(sceneName: sceneName, transitionName: "Fade"), + cancellationToken + ) + ) + .ConfigureAwait(false); + await Probe( + "SetSceneName", + () => + client.Scenes.SetSceneNameAsync( + new(newSceneName: renamedScene, sceneName: sceneName), + cancellationToken + ) + ) + .ConfigureAwait(false); + sceneName = renamedScene; + + // ── Scene items ────────────────────────────────────────────────── + await Probe( + "SetSceneItemEnabled", + () => + client.SceneItems.SetSceneItemEnabledAsync( + new(sceneItemId: itemId, sceneItemEnabled: true, sceneName: sceneName), + cancellationToken + ) + ) + .ConfigureAwait(false); + await Probe( + "SetSceneItemLocked", + () => + client.SceneItems.SetSceneItemLockedAsync( + new(sceneItemId: itemId, sceneItemLocked: false, sceneName: sceneName), + cancellationToken + ) + ) + .ConfigureAwait(false); + await Probe( + "SetSceneItemIndex", + () => + client.SceneItems.SetSceneItemIndexAsync( + new(sceneItemId: itemId, sceneItemIndex: 0, sceneName: sceneName), + cancellationToken + ) + ) + .ConfigureAwait(false); + await Probe( + "SetSceneItemBlendMode", + () => + client.SceneItems.SetSceneItemBlendModeAsync( + new( + sceneItemId: itemId, + sceneItemBlendMode: "OBS_BLEND_NORMAL", + sceneName: sceneName + ), + cancellationToken + ) + ) + .ConfigureAwait(false); + + // A whole transform read back from OBS is refused: it carries the source dimensions + // OBS computes and will not accept back. A caller sets the fields they mean to. + SceneItemTransformPatchStub transformPatch = new() + { + PositionX = 0.0, + PositionY = 0.0, + Rotation = 0.0, + }; + await Probe( + "SetSceneItemTransform", + () => + client.SceneItems.SetSceneItemTransformAsync( + new( + sceneItemId: itemId, + sceneItemTransform: transformPatch, + sceneName: sceneName + ), + cancellationToken + ) + ) + .ConfigureAwait(false); + + long? addedItemId = null; + try + { + CreateSceneItemResponseData added = await client + .SceneItems.CreateSceneItemAsync( + new(sceneName: sceneName, sourceName: inputName), + cancellationToken + ) + .ConfigureAwait(false); + addedItemId = added.SceneItemId; + sent++; + } + catch (ObsWebSocketRequestException ex) + { + declined.Add($"CreateSceneItem ({ex.StatusCode})"); + } + + long? duplicatedItemId = null; + try + { + DuplicateSceneItemResponseData duplicated = await client + .SceneItems.DuplicateSceneItemAsync( + new(sceneItemId: itemId, sceneName: sceneName), + cancellationToken + ) + .ConfigureAwait(false); + duplicatedItemId = duplicated.SceneItemId; + sent++; + } + catch (ObsWebSocketRequestException ex) + { + declined.Add($"DuplicateSceneItem ({ex.StatusCode})"); + } + + // Only the two this sweep added. Removing the last scene item that references an input + // destroys the input, which took the audio and media fixtures with it. + foreach (long extra in new[] { addedItemId, duplicatedItemId }.OfType()) + { + await Probe( + "RemoveSceneItem", + () => + client.SceneItems.RemoveSceneItemAsync( + new(sceneItemId: extra, sceneName: sceneName), + cancellationToken + ) + ) + .ConfigureAwait(false); + } + + // ── Inputs ─────────────────────────────────────────────────────── + await Probe( + "SetInputSettings", + () => + client.Inputs.SetInputSettingsAsync( + inputName, + new ColorSourceSettings { Width = 320, Height = 180 }, + cancellationToken: cancellationToken + ) + ) + .ConfigureAwait(false); + await Probe( + "SetInputMute", + () => + client.Inputs.SetInputMuteAsync( + new(inputMuted: false, inputName: audioInput), + cancellationToken + ) + ) + .ConfigureAwait(false); + await Probe( + "ToggleInputMute", + () => + client.Inputs.ToggleInputMuteAsync( + new(inputName: audioInput), + cancellationToken + ) + ) + .ConfigureAwait(false); + await Probe( + "SetInputVolume", + () => + client.Inputs.SetInputVolumeAsync( + new(inputName: audioInput, inputVolumeMul: 1.0), + cancellationToken + ) + ) + .ConfigureAwait(false); + await Probe( + "SetInputAudioBalance", + () => + client.Inputs.SetInputAudioBalanceAsync( + new(inputAudioBalance: 0.5, inputName: audioInput), + cancellationToken + ) + ) + .ConfigureAwait(false); + await Probe( + "SetInputAudioSyncOffset", + () => + client.Inputs.SetInputAudioSyncOffsetAsync( + new(inputAudioSyncOffset: 0, inputName: audioInput), + cancellationToken + ) + ) + .ConfigureAwait(false); + await Probe( + "SetInputAudioMonitorType", + () => + client.Inputs.SetInputAudioMonitorTypeAsync( + new(monitorType: "OBS_MONITORING_TYPE_NONE", inputName: audioInput), + cancellationToken + ) + ) + .ConfigureAwait(false); + + // The request that could not be sent over MessagePack at all. + GetInputAudioTracksResponseData? tracks = null; + try + { + tracks = await client + .Inputs.GetInputAudioTracksAsync(new(inputName: audioInput), cancellationToken) + .ConfigureAwait(false); + } + catch (ObsWebSocketRequestException) + { + // Deliberately not logged: the write below is probed either way and reports what + // OBS said about it. + } + await Probe( + "SetInputAudioTracks", + () => + client.Inputs.SetInputAudioTracksAsync( + new( + inputAudioTracks: tracks?.InputAudioTracks + ?? new Dictionary { ["1"] = true }, + inputName: audioInput + ), + cancellationToken + ) + ) + .ConfigureAwait(false); + + await Probe( + "SetInputDeinterlaceMode", + () => + client.Inputs.SetInputDeinterlaceModeAsync( + new( + inputDeinterlaceMode: "OBS_DEINTERLACE_MODE_DISABLE", + inputName: mediaInput + ), + cancellationToken + ) + ) + .ConfigureAwait(false); + await Probe( + "SetInputDeinterlaceFieldOrder", + () => + client.Inputs.SetInputDeinterlaceFieldOrderAsync( + new( + inputDeinterlaceFieldOrder: "OBS_DEINTERLACE_FIELD_ORDER_TOP", + inputName: mediaInput + ), + cancellationToken + ) + ) + .ConfigureAwait(false); + await Probe( + "SetInputName", + () => + client.Inputs.SetInputNameAsync( + new(newInputName: renamedInput, inputName: inputName), + cancellationToken + ) + ) + .ConfigureAwait(false); + inputName = renamedInput; + + // ── Media inputs ───────────────────────────────────────────────── + // The cursor requests only reach their own answer while the clip is playing. + await Probe( + "TriggerMediaInputAction (play)", + () => + client.MediaInputs.TriggerMediaInputActionAsync( + new(mediaAction: MediaInputAction.Play, inputName: mediaInput), + cancellationToken + ) + ) + .ConfigureAwait(false); + + // Playback starts a moment after OBS accepts the action, and the cursor requests are + // answered only once it has. + for (int attempt = 0; attempt < 20; attempt++) + { + GetMediaInputStatusResponseData status = await client + .MediaInputs.GetMediaInputStatusAsync( + new(inputName: mediaInput), + cancellationToken + ) + .ConfigureAwait(false); + if (status.MediaState is "OBS_MEDIA_STATE_PLAYING" or "OBS_MEDIA_STATE_PAUSED") + { + break; + } + + await Task.Delay(100, cancellationToken).ConfigureAwait(false); + } + + await Probe( + "SetMediaInputCursor", + () => + client.MediaInputs.SetMediaInputCursorAsync( + new(mediaCursor: 0, inputName: mediaInput), + cancellationToken + ) + ) + .ConfigureAwait(false); + await Probe( + "OffsetMediaInputCursor", + () => + client.MediaInputs.OffsetMediaInputCursorAsync( + new(mediaCursorOffset: 0, inputName: mediaInput), + cancellationToken + ) + ) + .ConfigureAwait(false); + await Probe( + "TriggerMediaInputAction", + () => + client.MediaInputs.TriggerMediaInputActionAsync( + new(mediaAction: MediaInputAction.Stop, inputName: mediaInput), + cancellationToken + ) + ) + .ConfigureAwait(false); + await Probe( + "PressInputPropertiesButton", + () => + client.Inputs.PressInputPropertiesButtonAsync( + new(propertyName: "__obsws_no_such_button", inputName: inputName), + cancellationToken + ) + ) + .ConfigureAwait(false); + + // ── Filters ────────────────────────────────────────────────────── + await Probe( + "CreateSourceFilter", + () => + client.Filters.CreateSourceFilterAsync( + new( + filterName: filterName, + filterKind: "color_filter_v2", + sourceName: inputName + ), + cancellationToken + ) + ) + .ConfigureAwait(false); + await Probe( + "SetSourceFilterEnabled", + () => + client.Filters.SetSourceFilterEnabledAsync( + new(filterName: filterName, filterEnabled: true, sourceName: inputName), + cancellationToken + ) + ) + .ConfigureAwait(false); + await Probe( + "SetSourceFilterIndex", + () => + client.Filters.SetSourceFilterIndexAsync( + new(filterName: filterName, filterIndex: 0, sourceName: inputName), + cancellationToken + ) + ) + .ConfigureAwait(false); + await Probe( + "SetSourceFilterSettings", + () => + client.Filters.SetSourceFilterSettingsAsync( + inputName, + filterName, + new ColorCorrectionFilterSettings { Opacity = 1.0 }, + cancellationToken: cancellationToken + ) + ) + .ConfigureAwait(false); + await Probe( + "SetSourceFilterName", + () => + client.Filters.SetSourceFilterNameAsync( + new( + filterName: filterName, + newFilterName: renamedFilter, + sourceName: inputName + ), + cancellationToken + ) + ) + .ConfigureAwait(false); + await Probe( + "RemoveSourceFilter", + () => + client.Filters.RemoveSourceFilterAsync( + new(filterName: renamedFilter, sourceName: inputName), + cancellationToken + ) + ) + .ConfigureAwait(false); + + // ── General ────────────────────────────────────────────────────── + using JsonDocument custom = JsonDocument.Parse("""{"obswsSweep":true}"""); + await Probe( + "BroadcastCustomEvent", + () => + client.General.BroadcastCustomEventAsync( + new(eventData: custom.RootElement.Clone()), + cancellationToken + ) + ) + .ConfigureAwait(false); + await Probe( + "TriggerHotkeyByKeySequence", + () => + client.General.TriggerHotkeyByKeySequenceAsync( + new( + keyId: "OBS_KEY_F13", + keyModifiers: new TriggerHotkeyByKeySequenceRequestData_KeyModifiers( + shift: false, + control: false, + alt: false, + command: false + ) + ), + cancellationToken + ) + ) + .ConfigureAwait(false); + + // ── Config, every one a read then a write of the same value ────── + await Probe( + "SetPersistentData", + () => + client.Config.SetPersistentDataAsync( + new( + realm: "OBS_WEBSOCKET_DATA_REALM_PROFILE", + slotName: "__obsws_sweep", + slotValue: JsonDocument.Parse("\"probe\"").RootElement.Clone() + ), + cancellationToken + ) + ) + .ConfigureAwait(false); + + // CreateProfile activates the profile it creates, and removing the active one leaves + // OBS writing into a directory it has deleted (#42). Switch back first, and wait for + // each step: OBS answers these before it applies them. + string sweepProfile = $"__obsws_wsweep_profile_{suffix}"; + GetProfileListResponseData profilesBefore = await client + .Config.GetProfileListAsync(cancellationToken) + .ConfigureAwait(false); + string originalProfile = profilesBefore.CurrentProfileName!; + + await Probe( + "CreateProfile", + () => + client.Config.CreateProfileAsync( + new(profileName: sweepProfile), + cancellationToken + ) + ) + .ConfigureAwait(false); + + if ( + await WaitForProfileAsync( + client, + list => list.CurrentProfileName == sweepProfile, + cancellationToken + ) + .ConfigureAwait(false) + ) + { + await Probe( + "SetCurrentProfile", + () => + client.Config.SetCurrentProfileAsync( + new(profileName: originalProfile), + cancellationToken + ) + ) + .ConfigureAwait(false); + + if ( + await WaitForProfileAsync( + client, + list => list.CurrentProfileName == originalProfile, + cancellationToken + ) + .ConfigureAwait(false) + ) + { + await Probe( + "RemoveProfile", + () => + client.Config.RemoveProfileAsync( + new(profileName: sweepProfile), + cancellationToken + ) + ) + .ConfigureAwait(false); + _ = await WaitForProfileAsync( + client, + list => !list.Profiles.Contains(sweepProfile), + cancellationToken + ) + .ConfigureAwait(false); + } + else + { + declined.Add("RemoveProfile (not sent: OBS stayed on the sweep's profile)"); + } + } + else + { + declined.Add( + "SetCurrentProfile, RemoveProfile (not sent: the new profile never became active)" + ); + } + + GetProfileParameterResponseData profileParameter = await client + .Config.GetProfileParameterAsync( + new(parameterCategory: "Output", parameterName: "Mode"), + cancellationToken + ) + .ConfigureAwait(false); + await Probe( + "SetProfileParameter", + () => + client.Config.SetProfileParameterAsync( + new( + parameterCategory: "Output", + parameterName: "Mode", + parameterValue: profileParameter.ParameterValue + ?? profileParameter.DefaultParameterValue + ?? "Simple" + ), + cancellationToken + ) + ) + .ConfigureAwait(false); + + GetVideoSettingsResponseData video = await client + .Config.GetVideoSettingsAsync(cancellationToken) + .ConfigureAwait(false); + await Probe( + "SetVideoSettings", + () => + client.Config.SetVideoSettingsAsync( + new( + fpsNumerator: video.FpsNumerator, + fpsDenominator: video.FpsDenominator, + baseWidth: video.BaseWidth, + baseHeight: video.BaseHeight, + outputWidth: video.OutputWidth, + outputHeight: video.OutputHeight + ), + cancellationToken + ) + ) + .ConfigureAwait(false); + + GetRecordDirectoryResponseData recordDirectory = await client + .Config.GetRecordDirectoryAsync(cancellationToken) + .ConfigureAwait(false); + await Probe( + "SetRecordDirectory", + () => + client.Config.SetRecordDirectoryAsync( + new(recordDirectory: recordDirectory.RecordDirectory), + cancellationToken + ) + ) + .ConfigureAwait(false); + + GetStreamServiceSettingsResponseData streamService = await client + .Config.GetStreamServiceSettingsAsync(cancellationToken) + .ConfigureAwait(false); + // Written back unchanged where OBS has settings. A fresh install has none and OBS + // rejects an empty object, so the request would never be exercised; a placeholder for + // the service it already reports keeps the check real and overwrites nothing. + JsonElement serviceSettings = + streamService.StreamServiceSettings is JsonElement existing + && existing.ValueKind == JsonValueKind.Object + && existing.EnumerateObject().Any() + ? existing + : JsonDocument + .Parse("""{"server":"auto","service":"Twitch"}""") + .RootElement.Clone(); + await Probe( + "SetStreamServiceSettings", + () => + client.Config.SetStreamServiceSettingsAsync( + new( + streamServiceType: streamService.StreamServiceType, + streamServiceSettings: serviceSettings + ), + cancellationToken + ) + ) + .ConfigureAwait(false); + + // ── Transitions, same read then write ──────────────────────────── + GetCurrentSceneTransitionResponseData transition = await client + .Transitions.GetCurrentSceneTransitionAsync(cancellationToken) + .ConfigureAwait(false); + await Probe( + "SetCurrentSceneTransition", + () => + client.Transitions.SetCurrentSceneTransitionAsync( + new(transitionName: transition.TransitionName), + cancellationToken + ) + ) + .ConfigureAwait(false); + await Probe( + "SetCurrentSceneTransitionDuration", + () => + client.Transitions.SetCurrentSceneTransitionDurationAsync( + new(transitionDuration: transition.TransitionDuration ?? 300), + cancellationToken + ) + ) + .ConfigureAwait(false); + // Fade and Cut have nothing to configure and OBS answers 606. Select a configurable + // transition in OBS to exercise this one; the CI scene collection ships with one. + if (transition.TransitionConfigurable) + { + await Probe( + "SetCurrentSceneTransitionSettings", + () => + client.Transitions.SetCurrentSceneTransitionSettingsAsync( + new( + transitionSettings: transition.TransitionSettings + ?? JsonDocument.Parse("{}").RootElement.Clone() + ), + cancellationToken + ) + ) + .ConfigureAwait(false); + } + else + { + declined.Add( + $"SetCurrentSceneTransitionSettings (not sent: '{transition.TransitionName}' has nothing to configure)" + ); + } + + // ── UI and studio mode, restored below ─────────────────────────── + GetStudioModeEnabledResponseData studio = await client + .Ui.GetStudioModeEnabledAsync(cancellationToken) + .ConfigureAwait(false); + await Probe( + "SetStudioModeEnabled", + () => client.Ui.SetStudioModeEnabledAsync(new(true), cancellationToken) + ) + .ConfigureAwait(false); + await Probe( + "SetCurrentPreviewScene", + () => + client.Scenes.SetCurrentPreviewSceneAsync( + new(sceneName: sceneName), + cancellationToken + ) + ) + .ConfigureAwait(false); + await Probe( + "SetTBarPosition", + () => + client.Transitions.SetTBarPositionAsync( + new(position: 0.0, release: true), + cancellationToken + ) + ) + .ConfigureAwait(false); + await Probe( + "TriggerStudioModeTransition", + () => client.Transitions.TriggerStudioModeTransitionAsync(cancellationToken) + ) + .ConfigureAwait(false); + if (!studio.StudioModeEnabled) + { + await client + .Ui.SetStudioModeEnabledAsync(new(false), CancellationToken.None) + .ConfigureAwait(false); + } + + // ── Requests OBS should decline in this state, sent anyway so the + // request side is still exercised ─────────────────────────────── + await Probe("StopRecord", () => client.Record.StopRecordAsync(cancellationToken)) + .ConfigureAwait(false); + await Probe( + "ToggleRecordPause", + () => client.Record.ToggleRecordPauseAsync(cancellationToken) + ) + .ConfigureAwait(false); + await Probe("PauseRecord", () => client.Record.PauseRecordAsync(cancellationToken)) + .ConfigureAwait(false); + await Probe("ResumeRecord", () => client.Record.ResumeRecordAsync(cancellationToken)) + .ConfigureAwait(false); + await Probe( + "SplitRecordFile", + () => client.Record.SplitRecordFileAsync(cancellationToken) + ) + .ConfigureAwait(false); + await Probe( + "CreateRecordChapter", + () => + client.Record.CreateRecordChapterAsync( + new(chapterName: "__obsws_sweep"), + cancellationToken + ) + ) + .ConfigureAwait(false); + await Probe("StopStream", () => client.Stream.StopStreamAsync(cancellationToken)) + .ConfigureAwait(false); + await Probe( + "SendStreamCaption", + () => + client.Stream.SendStreamCaptionAsync( + new(captionText: "__obsws_sweep"), + cancellationToken + ) + ) + .ConfigureAwait(false); + await Probe( + "StopReplayBuffer", + () => client.Outputs.StopReplayBufferAsync(cancellationToken) + ) + .ConfigureAwait(false); + await Probe( + "SaveReplayBuffer", + () => client.Outputs.SaveReplayBufferAsync(cancellationToken) + ) + .ConfigureAwait(false); + + await Probe( + "StopOutput", + () => + client.Outputs.StopOutputAsync( + new(outputName: outputName), + cancellationToken + ) + ) + .ConfigureAwait(false); + + // SetOutputSettings is deliberately not sent. Writing settings back to a real + // output wedged the output subsystem: GetOutputList then timed out for the rest of + // the session, in checks that had nothing to do with the sweep. + declined.Add("SetOutputSettings (not sent: wedges the output subsystem)"); + + // ── Sources ────────────────────────────────────────────────────── + string screenshotPath = Path.Combine(Path.GetTempPath(), $"obsws_sweep_{suffix}.png"); + await Probe( + "SaveSourceScreenshot", + () => + client.Sources.SaveSourceScreenshotAsync( + new( + imageFormat: "png", + imageFilePath: screenshotPath, + sourceName: sceneName + ), + cancellationToken + ) + ) + .ConfigureAwait(false); + if (File.Exists(screenshotPath)) + { + File.Delete(screenshotPath); + } + } + finally + { + await client + .Scenes.SetCurrentProgramSceneAsync( + new(sceneName: originalProgramScene), + CancellationToken.None + ) + .ConfigureAwait(false); + + foreach (string fixture in new[] { inputName, audioInput, mediaInput }) + { + await Probe( + "RemoveInput", + () => + client.Inputs.RemoveInputAsync( + new(inputName: fixture), + CancellationToken.None + ) + ) + .ConfigureAwait(false); + } + await Probe( + "RemoveScene", + () => + client.Scenes.RemoveSceneAsync( + new(sceneName: sceneName), + CancellationToken.None + ) + ) + .ConfigureAwait(false); + } + + return + [ + ( + "Every write request serializes", + unsendable.Count == 0, + unsendable.Count == 0 + // A decline still proves the request serialized and reached OBS, which is + // what this sweep covers; only unsendable is a defect. + ? $"{sent + declined.Count} serialized ({sent} accepted, {declined.Count} " + + $"declined for machine state): {string.Join(", ", declined)}" + : string.Join(" | ", unsendable.Take(3)) + ), + ]; + } + + /// + /// The settings-mode checks themselves, against fixtures the caller creates and removes. + /// + internal static async Task< + List<(string Label, bool Pass, string Detail)> + > RunSettingsModeChecksAsync( + ObsWebSocketClient client, + string? browserInputName, + string? filterSourceName, + string? gainFilterName, + CancellationToken cancellationToken + ) + { + List<(string Label, bool Pass, string Detail)> results = []; + + if (string.IsNullOrEmpty(browserInputName)) + { + results.Add( + ("InputSettings [all modes]", false, "fixture browser source was not created") + ); + } + else + { + // Mode 1: raw JsonElement via protocol-level call + results.Add( + await TrySettingsCheckAsync( + "InputSettings Mode1 (raw JsonElement)", + async () => + { + GetInputSettingsResponseData? r = await client.Inputs.GetInputSettingsAsync( + new GetInputSettingsRequestData(browserInputName), + cancellationToken + ); + if (r?.InputSettings is not JsonElement el) + { + return (false, "null InputSettings in response"); + } + + await client.Inputs.SetInputSettingsAsync( + new SetInputSettingsRequestData( + el, + inputName: browserInputName, + overlay: true + ), + cancellationToken + ); + string url = el.TryGetProperty("url", out JsonElement p) + ? p.GetString() ?? "(no url)" + : "(no url key)"; + return (true, $"'{browserInputName}' url={url}"); + } + ) + ); + + // Mode 2: library-registered type via implicit GetTypeInfo lookup + results.Add( + await TrySettingsCheckAsync( + "InputSettings Mode2 (BrowserSourceSettings)", + async () => + { + BrowserSourceSettings? s = + await client.Inputs.GetInputSettingsAsync( + browserInputName, + cancellationToken + ); + if (s is null) + { + return (false, "null result"); + } + + await client.Inputs.SetInputSettingsAsync( + browserInputName, + s, + overlay: true, + cancellationToken: cancellationToken + ); + return (true, $"'{browserInputName}' url={s.Url ?? "(null)"}"); + } + ) + ); + + // Mode 3: consumer-defined type with explicit JsonTypeInfo + results.Add( + await TrySettingsCheckAsync( + "InputSettings Mode3 (consumer JsonTypeInfo)", + async () => + { + JsonTypeInfo typeInfo = WorkerSettingsJsonContext + .Default + .WorkerBrowserUrlSettings; + WorkerBrowserUrlSettings? s = await client.Inputs.GetInputSettingsAsync( + browserInputName, + typeInfo, + cancellationToken + ); + if (s is null) + { + return (false, "null result"); + } + + await client.Inputs.SetInputSettingsAsync( + browserInputName, + s, + typeInfo, + overlay: true, + cancellationToken: cancellationToken + ); + return (true, $"'{browserInputName}' url={s.Url ?? "(null)"}"); + } + ) + ); + } + + // ── FilterSettings ──────────────────────────────────────────────────── + if (string.IsNullOrEmpty(filterSourceName) || string.IsNullOrEmpty(gainFilterName)) + { + results.Add( + ("FilterSettings [all modes]", false, "fixture gain filter was not created") + ); + } + else + { + // Mode 1: raw JsonElement via protocol-level call + results.Add( + await TrySettingsCheckAsync( + "FilterSettings Mode1 (raw JsonElement)", + async () => + { + GetSourceFilterResponseData? r = await client.Filters.GetSourceFilterAsync( + new GetSourceFilterRequestData + { + SourceName = filterSourceName, + FilterName = gainFilterName, + }, + cancellationToken + ); + if (r?.FilterSettings is not JsonElement el) + { + return (false, "null FilterSettings in response"); + } + + await client.Filters.SetSourceFilterSettingsAsync( + new SetSourceFilterSettingsRequestData( + gainFilterName, + el, + sourceName: filterSourceName, + overlay: true + ), + cancellationToken + ); + string db = el.TryGetProperty("db", out JsonElement p) + ? p.GetDouble().ToString("F1") + : "(no db key)"; + return (true, $"'{filterSourceName}/{gainFilterName}' db={db}"); + } + ) + ); + + // Mode 2: library-registered type via implicit GetTypeInfo lookup + results.Add( + await TrySettingsCheckAsync( + "FilterSettings Mode2 (GainFilterSettings)", + async () => + { + GainFilterSettings? s = + await client.Filters.GetSourceFilterSettingsAsync( + filterSourceName, + gainFilterName, + cancellationToken + ); + if (s is null) + { + return (false, "null result"); + } + + await client.Filters.SetSourceFilterSettingsAsync( + filterSourceName, + gainFilterName, + s, + overlay: true, + cancellationToken: cancellationToken + ); + return ( + true, + $"'{filterSourceName}/{gainFilterName}' db={s.Db?.ToString("F1") ?? "(null)"}" + ); + } + ) + ); + + // Mode 3: consumer-defined type with explicit JsonTypeInfo + results.Add( + await TrySettingsCheckAsync( + "FilterSettings Mode3 (consumer JsonTypeInfo)", + async () => + { + JsonTypeInfo typeInfo = WorkerSettingsJsonContext + .Default + .WorkerGainDbSettings; + WorkerGainDbSettings? s = await client.Filters.GetSourceFilterSettingsAsync( + filterSourceName, + gainFilterName, + typeInfo, + cancellationToken + ); + if (s is null) + { + return (false, "null result"); + } + + await client.Filters.SetSourceFilterSettingsAsync( + filterSourceName, + gainFilterName, + s, + typeInfo, + overlay: true, + cancellationToken: cancellationToken + ); + return ( + true, + $"'{filterSourceName}/{gainFilterName}' db={s.Db?.ToString("F1") ?? "(null)"}" + ); + } + ) + ); + } + + return results; + } + + /// + /// Runs an action expected to throw, and hands back the exception it threw. + /// + internal static async Task ExpectThrowAsync(Func action) + where TException : Exception + { + try + { + await action().ConfigureAwait(false); + } + catch (TException expected) + { + return expected; + } + + throw new InvalidOperationException( + $"Expected {typeof(TException).Name}, but the call succeeded." + ); + } +} + +/// +/// A request payload this library has no metadata for, sent with the consumer's own context. +/// +/// The scene to list items for. +internal sealed record ConsumerSceneRequest( + [property: System.Text.Json.Serialization.JsonPropertyName("sceneName")] string SceneName +); + +/// +/// The consumer side serializer context, the AOT safe way to describe a payload the library does +/// not model. +/// +[System.Text.Json.Serialization.JsonSerializable(typeof(ConsumerSceneRequest))] +internal sealed partial class ExampleRequestContext + : System.Text.Json.Serialization.JsonSerializerContext; + +// Settings types a consumer would define, deliberately absent from the library's context.──────────────────────── +// These are NOT registered in the library's ObsWebSocketSettingsJsonContext. +// They represent what a consumer app would define to map only the fields it cares about, +// using its own JsonSerializerContext and passing an explicit JsonTypeInfo to the helpers. + +internal sealed record WorkerBrowserUrlSettings( + [property: JsonPropertyName("url")] string? Url = null +); + +internal sealed record WorkerGainDbSettings([property: JsonPropertyName("db")] double? Db = null); + +[JsonSerializable(typeof(WorkerBrowserUrlSettings))] +[JsonSerializable(typeof(WorkerGainDbSettings))] +[JsonSourceGenerationOptions( + PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault +)] +internal sealed partial class WorkerSettingsJsonContext : JsonSerializerContext { } diff --git a/ObsWebSocket.Example/ObsSourceKinds.cs b/ObsWebSocket.Tests/Integration/ObsSourceKinds.cs similarity index 98% rename from ObsWebSocket.Example/ObsSourceKinds.cs rename to ObsWebSocket.Tests/Integration/ObsSourceKinds.cs index 799730a..2de0b0d 100644 --- a/ObsWebSocket.Example/ObsSourceKinds.cs +++ b/ObsWebSocket.Tests/Integration/ObsSourceKinds.cs @@ -2,7 +2,7 @@ using ObsWebSocket.Core.Protocol.Requests; using ObsWebSocket.Core.Protocol.Responses; -namespace ObsWebSocket.Example; +namespace ObsWebSocket.Tests.Integration; /// /// The input and filter kinds the connected OBS offers. diff --git a/ObsWebSocket.Example/SerializationFailureSink.cs b/ObsWebSocket.Tests/Integration/SerializationFailureSink.cs similarity index 98% rename from ObsWebSocket.Example/SerializationFailureSink.cs rename to ObsWebSocket.Tests/Integration/SerializationFailureSink.cs index a1e35c1..cdd11e3 100644 --- a/ObsWebSocket.Example/SerializationFailureSink.cs +++ b/ObsWebSocket.Tests/Integration/SerializationFailureSink.cs @@ -2,7 +2,7 @@ using Microsoft.Extensions.Logging; using ObsWebSocket.Core; -namespace ObsWebSocket.Example; +namespace ObsWebSocket.Tests.Integration; /// /// Records every payload the client could not deserialize. diff --git a/ObsWebSocket.Tests/Integration/TransportValidationTests.cs b/ObsWebSocket.Tests/Integration/TransportValidationTests.cs new file mode 100644 index 0000000..35959da --- /dev/null +++ b/ObsWebSocket.Tests/Integration/TransportValidationTests.cs @@ -0,0 +1,90 @@ +using ObsWebSocket.Core; +using ObsWebSocket.Core.Protocol.Responses; + +namespace ObsWebSocket.Tests.Integration; + +/// +/// Sends every request the protocol defines against a live OBS, on both wire formats. +/// +/// +/// One test per format rather than one per sweep. The sweeps share a single OBS and the write +/// sweep changes global state, so they are ordered rather than independent; every check still +/// reports on its own, and a failure names all of them at once. +/// +[TestClass] +[DoNotParallelize] +[TestCategory("Integration")] +public sealed class TransportValidationTests +{ + private const int CycleTimeoutMs = 600_000; + + /// The context MSTest assigns, used for cancellation. + public TestContext TestContext { get; set; } = null!; + + [TestMethod] + [DataRow(SerializationFormat.Json)] + [DataRow(SerializationFormat.MsgPack)] + [Timeout(CycleTimeoutMs, CooperativeCancellation = true)] + public async Task EveryRequest_LiveObs_RoundTrips(SerializationFormat format) + { + CancellationToken token = TestContext.CancellationToken; + await using LiveClient live = await LiveClient + .ConnectAsync(format, TestContext) + .ConfigureAwait(false); + + SerializationFailureSink.Reset(); + + GetInputListResponseData inputs = await live + .Client.Inputs.GetInputListAsync(new(), token) + .ConfigureAwait(false); + + List<(string Label, bool Pass, string Detail)> results = + [ + .. await ObsRequestSweeps + .ValidateSettingsModesAsync(live.Client, inputs, live.Kinds, token) + .ConfigureAwait(false), + .. await ObsRequestSweeps + .ValidateModernApisAsync( + live.Client, + live.HealthChecks, + live.Kinds, + live.Outputs, + token + ) + .ConfigureAwait(false), + .. await ObsRequestSweeps + .SweepEveryReadRequestAsync(live.Client, live.Kinds, live.Outputs, token) + .ConfigureAwait(false), + .. await ObsRequestSweeps + .SweepEveryWriteRequestAsync(live.Client, live.Kinds, live.Outputs, token) + .ConfigureAwait(false), + ]; + + string[] unreadable = [.. SerializationFailureSink.Failures.Distinct()]; + results.Add( + ( + "No unreadable payloads", + unreadable.Length == 0, + unreadable.Length == 0 + ? "every payload the run received deserialized" + : string.Join(" | ", unreadable.Take(3)) + ) + ); + + foreach ((string label, bool pass, string detail) in results) + { + TestContext.WriteLine($"{(pass ? "pass" : "FAIL")} {label}: {detail}"); + } + + Assert.IsNotEmpty(results, "the sweep reported nothing, which means it did not run"); + + string[] failures = + [ + .. results + .Where(result => !result.Pass) + .Select(result => $"{result.Label}: {result.Detail}"), + ]; + + Assert.IsEmpty(failures, string.Join(Environment.NewLine, failures)); + } +} diff --git a/ObsWebSocket.Tests/Integration/TypedSettingsHelperTests.cs b/ObsWebSocket.Tests/Integration/TypedSettingsHelperTests.cs new file mode 100644 index 0000000..4ef1b1a --- /dev/null +++ b/ObsWebSocket.Tests/Integration/TypedSettingsHelperTests.cs @@ -0,0 +1,211 @@ +using ObsWebSocket.Core; +using ObsWebSocket.Core.Protocol.Common; +using ObsWebSocket.Core.Protocol.Common.FilterSettings; +using ObsWebSocket.Core.Protocol.Common.InputSettings; +using ObsWebSocket.Core.Protocol.Responses; + +namespace ObsWebSocket.Tests.Integration; + +/// +/// Covers the typed settings helpers on each group: read as a library type, read as a raw +/// element, and write back what was read. +/// +[TestClass] +[DoNotParallelize] +[TestCategory("Integration")] +public sealed class TypedSettingsHelperTests +{ + private const int TimeoutMs = 120_000; + + /// The context MSTest assigns, used for cancellation. + public TestContext TestContext { get; set; } = null!; + + [TestMethod] + [Timeout(TimeoutMs, CooperativeCancellation = true)] + public async Task TransitionSettings_LiveObs_RoundTrip() + { + CancellationToken token = TestContext.CancellationToken; + await using LiveClient live = await LiveClient + .ConnectAsync(SerializationFormat.Json, TestContext) + .ConfigureAwait(false); + + GetSceneTransitionListResponseData transitions = await live + .Client.Transitions.GetSceneTransitionListAsync(token) + .ConfigureAwait(false); + + // Chosen by kind rather than taken from whichever is active: the built-in Cut and Fade + // have no properties, and the record below models a swipe. obs-websocket cannot create a + // transition, so one has to come from the scene collection, as the CI collection's does. + TransitionStub? swipe = transitions.Transitions.Find(t => + t.TransitionKind == "swipe_transition" + ); + if (swipe is null) + { + ObsLiveEndpoint.Unavailable( + TestContext, + "This scene collection has no swipe transition, and obs-websocket cannot add one." + ); + } + + string? original = transitions.CurrentSceneTransitionName; + await live + .Client.Transitions.SetCurrentSceneTransitionAsync(new(swipe.TransitionName), token) + .ConfigureAwait(false); + + // The library models no transition settings type, so this goes through the overload a + // consumer would use: their own record plus its metadata. + SwipeSettings before = + await live + .Client.Transitions.GetCurrentSceneTransitionSettingsAsync( + TransitionSettingsContext.Default.SwipeSettings, + token + ) + .ConfigureAwait(false) + ?? new SwipeSettings(); + + try + { + SwipeSettings changed = before with { SwipeIn = !(before.SwipeIn ?? false) }; + await live + .Client.Transitions.SetCurrentSceneTransitionSettingsAsync( + changed, + TransitionSettingsContext.Default.SwipeSettings, + cancellationToken: token + ) + .ConfigureAwait(false); + + SwipeSettings? after = await live + .Client.Transitions.GetCurrentSceneTransitionSettingsAsync( + TransitionSettingsContext.Default.SwipeSettings, + token + ) + .ConfigureAwait(false); + + Assert.AreEqual(changed.SwipeIn, after?.SwipeIn, "the written value must read back"); + } + finally + { + await live + .Client.Transitions.SetCurrentSceneTransitionSettingsAsync( + before, + TransitionSettingsContext.Default.SwipeSettings, + cancellationToken: CancellationToken.None + ) + .ConfigureAwait(false); + if (original is not null) + { + await live + .Client.Transitions.SetCurrentSceneTransitionAsync( + new(original), + CancellationToken.None + ) + .ConfigureAwait(false); + } + } + } + + [TestMethod] + [Timeout(TimeoutMs, CooperativeCancellation = true)] + public async Task OutputSettings_LiveObs_ReadRawAndTyped() + { + CancellationToken token = TestContext.CancellationToken; + await using LiveClient live = await LiveClient + .ConnectAsync(SerializationFormat.Json, TestContext) + .ConfigureAwait(false); + + string outputName = live.Outputs.Outputs[0].OutputName; + + // Read only. Writing settings back to a real output wedges the output subsystem. + ColorSourceSettings? settings = await live + .Client.Outputs.GetOutputSettingsAsync(outputName, token) + .ConfigureAwait(false); + + TestContext.WriteLine($"{outputName} settings present: {settings is not null}"); + } + + [TestMethod] + [Timeout(TimeoutMs, CooperativeCancellation = true)] + public async Task InputAndFilterSettings_LiveObs_RoundTrip() + { + CancellationToken token = TestContext.CancellationToken; + await using LiveClient live = await LiveClient + .ConnectAsync(SerializationFormat.Json, TestContext) + .ConfigureAwait(false); + + string input = $"__obsws_typed_{Guid.NewGuid():N}"[..26]; + GetSceneListResponseData scenes = await live + .Client.Scenes.GetSceneListAsync(new(), token) + .ConfigureAwait(false); + + await live + .Client.Inputs.CreateInputAsync( + new( + inputName: input, + inputKind: live.Kinds.Color!, + sceneName: scenes.CurrentProgramSceneName! + ), + token + ) + .ConfigureAwait(false); + + try + { + ColorSourceSettings? raw = await live + .Client.Inputs.GetInputSettingsAsync(input, token) + .ConfigureAwait(false); + Assert.IsNotNull(raw); + + await live + .Client.Inputs.SetInputSettingsAsync(input, raw, cancellationToken: token) + .ConfigureAwait(false); + + if (live.Kinds.ColorFilter is { } filterKind) + { + await live + .Client.Filters.CreateSourceFilterAsync( + new( + filterName: "__obsws_typed_filter", + filterKind: filterKind, + sourceName: input + ), + token + ) + .ConfigureAwait(false); + + ColorCorrectionFilterSettings? filterRaw = await live + .Client.Filters.GetSourceFilterSettingsAsync( + input, + "__obsws_typed_filter", + token + ) + .ConfigureAwait(false); + + await live + .Client.Filters.SetSourceFilterSettingsAsync( + input, + "__obsws_typed_filter", + filterRaw ?? new ColorCorrectionFilterSettings(), + cancellationToken: token + ) + .ConfigureAwait(false); + } + } + finally + { + await live + .Client.Inputs.RemoveInputAsync(new(inputName: input), CancellationToken.None) + .ConfigureAwait(false); + } + } +} + +/// Settings for the swipe transition, as a consumer would model them. +internal sealed record SwipeSettings( + [property: System.Text.Json.Serialization.JsonPropertyName("direction")] + string? Direction = null, + [property: System.Text.Json.Serialization.JsonPropertyName("swipe_in")] bool? SwipeIn = null +); + +[System.Text.Json.Serialization.JsonSerializable(typeof(SwipeSettings))] +internal sealed partial class TransitionSettingsContext + : System.Text.Json.Serialization.JsonSerializerContext { } diff --git a/ObsWebSocket.Tests/ObsIntegrationTestOptions.cs b/ObsWebSocket.Tests/ObsIntegrationTestOptions.cs deleted file mode 100644 index a5e3cab..0000000 --- a/ObsWebSocket.Tests/ObsIntegrationTestOptions.cs +++ /dev/null @@ -1,46 +0,0 @@ -namespace ObsWebSocket.Tests; - -/// -/// Configuration options specifically for OBS integration tests. -/// Populated from the "ObsIntegration" section of testsettings.local.json. -/// -internal sealed class ObsIntegrationTestOptions -{ - /// - /// Gets or sets the WebSocket Server URI for the OBS instance used in tests. - /// Example: "ws://localhost:4455" - /// - public string? ServerUri { get; set; } - - /// - /// Gets or sets the WebSocket Server password, if authentication is enabled in OBS. - /// Leave null or empty if authentication is disabled. - /// - public string? Password { get; set; } - - /// - /// Gets or sets the name of the scene required to exist in OBS for certain integration tests. - /// Example: "IntegrationTestScene" - /// - public string? TestSceneName { get; set; } - - /// - /// Gets or sets the name of a specific input source (e.g., a Text GDI+ source) - /// expected to exist within the for certain tests. - /// Example: "IntegrationTestInput" - /// - public string? TestInputName { get; set; } - - /// - /// Gets or sets the name of a specific filter (e.g., a Color Correction filter) - /// expected to exist on the source for certain tests. - /// Example: "IntegrationTestFilter" - /// - public string? TestFilterName { get; set; } - - /// - /// Gets or sets the name of an audio input source (e.g., Mic/Aux) for testing audio features. - /// Example: "Mic/Aux" - /// - public string? TestAudioInputName { get; set; } -} diff --git a/ObsWebSocket.Tests/ObsWebSocket.Tests.csproj b/ObsWebSocket.Tests/ObsWebSocket.Tests.csproj index 4738569..954140f 100644 --- a/ObsWebSocket.Tests/ObsWebSocket.Tests.csproj +++ b/ObsWebSocket.Tests/ObsWebSocket.Tests.csproj @@ -44,6 +44,12 @@ PreserveNewest + + PreserveNewest + + + PreserveNewest + diff --git a/ObsWebSocket.Tests/ObsWebSocketClientIntegrationTests.cs b/ObsWebSocket.Tests/ObsWebSocketClientIntegrationTests.cs deleted file mode 100644 index 22686b8..0000000 --- a/ObsWebSocket.Tests/ObsWebSocketClientIntegrationTests.cs +++ /dev/null @@ -1,854 +0,0 @@ -using System.Diagnostics; -using System.Text.Json; -using System.Text.Json.Serialization; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using ObsWebSocket.Core; -using ObsWebSocket.Core.Events.Generated; -using ObsWebSocket.Core.Networking; -using ObsWebSocket.Core.Protocol.Common; -using ObsWebSocket.Core.Protocol.Requests; -using ObsWebSocket.Core.Protocol.Responses; -using ObsWebSocket.Core.Serialization; - -namespace ObsWebSocket.Tests; - -/// -/// Integration tests for ObsWebSocketClient connecting to a live OBS instance. -/// Requires OBS Studio running with the WebSocket server enabled. -/// Connection details are read from 'testsettings.local.json' (ensure this file exists and is configured). -/// -[TestClass] -[DoNotParallelize] // Ensure tests run sequentially against the OBS instance -[TestCategory("Integration")] -public class ObsWebSocketClientIntegrationTests -{ - private static IServiceProvider s_serviceProvider = null!; - private static ObsIntegrationTestOptions s_testOptions = null!; - private const int DefaultTimeoutMs = 10000; // Default for most tests - private const int EventTimeoutMs = 15000; // Allow more time for manual interaction tests - - public TestContext TestContext { get; set; } = null!; - - [ClassInitialize] - public static void ClassInitialize(TestContext context) - { - bool enableTrace = Environment.GetEnvironmentVariable("CI_TRACE") == "1"; - LogLevel minLogLevel = enableTrace ? LogLevel.Trace : LogLevel.Information; - - IConfigurationRoot configuration = new ConfigurationBuilder() - .SetBasePath(Directory.GetCurrentDirectory()) - .AddJsonFile("testsettings.json", optional: true) // Base settings (optional) - .AddJsonFile("testsettings.local.json", optional: false, reloadOnChange: false) // Local overrides (required) - .AddEnvironmentVariables() - .Build(); - - ServiceCollection services = new(); - _ = services.AddLogging(builder => builder.AddConsole().SetMinimumLevel(minLogLevel)); - - // Bind the "ObsIntegration" section from configuration to the options class - _ = services.Configure( - configuration.GetSection("ObsIntegration") - ); - - // Configure ObsWebSocketClientOptions based on ObsIntegrationTestOptions - _ = services - .AddOptions() - .Configure>( - (coreOptions, testOpts) => - { - coreOptions.ServerUri = - testOpts.Value.ServerUri != null ? new Uri(testOpts.Value.ServerUri) : null; - coreOptions.Password = testOpts.Value.Password; - coreOptions.AutoReconnectEnabled = false; // Disable auto-reconnect for tests - } - ); - - // Add the OBS WebSocket client services - _ = services.AddObsWebSocketClient(); - - s_serviceProvider = services.BuildServiceProvider(); - s_testOptions = s_serviceProvider - .GetRequiredService>() - .Value; - - // --- Configuration Validation --- - static void ValidateOption(string? value, string name) - { - Assert.IsFalse( - string.IsNullOrWhiteSpace(value), - $"{name} must be configured in testsettings.local.json ('ObsIntegration' section)" - ); - Trace.WriteLine($"Using {name}: '{value}'"); - } - - ValidateOption(s_testOptions.ServerUri, nameof(s_testOptions.ServerUri)); - context.WriteLine( - s_testOptions.Password is { Length: > 0 } - ? "Using password authentication." - : "Using no authentication." - ); - ValidateOption(s_testOptions.TestSceneName, nameof(s_testOptions.TestSceneName)); - ValidateOption(s_testOptions.TestInputName, nameof(s_testOptions.TestInputName)); - ValidateOption(s_testOptions.TestFilterName, nameof(s_testOptions.TestFilterName)); - ValidateOption(s_testOptions.TestAudioInputName, nameof(s_testOptions.TestAudioInputName)); - } - - [ClassCleanup] - public static async Task ClassCleanup() - { - if (s_serviceProvider is IAsyncDisposable asyncDisposable) - { - await asyncDisposable.DisposeAsync(); - } - else if (s_serviceProvider is IDisposable disposable) - { - disposable.Dispose(); - } - } - - private static ObsWebSocketClient CreateClient() - { - ILogger logger = s_serviceProvider.GetRequiredService< - ILogger - >(); - ObsSerializerFactory serializerFactory = - s_serviceProvider.GetRequiredService(); - IOptions options = s_serviceProvider.GetRequiredService< - IOptions - >(); - IWebSocketConnectionFactory connectionFactory = - s_serviceProvider.GetRequiredService(); - - return new ObsWebSocketClient(logger, serializerFactory, options, connectionFactory); - } - - // --- Test Cases --- - - [TestMethod, TestCategory("Integration")] - [Timeout(DefaultTimeoutMs, CooperativeCancellation = true)] - public async Task ConnectDisconnect_ValidUri_Succeeds() - { - /* OBS Setup: Requires OBS running with WebSocket enabled at the configured URI. */ - ObsWebSocketClient client = CreateClient(); - bool connectedEventFired = false; - bool disconnectedEventFired = false; - Exception? disconnectReason = new("Placeholder"); // Start non-null to check for graceful disconnect - - client.Connected += (_, _) => connectedEventFired = true; - client.Disconnected += (_, e) => - { - disconnectedEventFired = true; - disconnectReason = e.ReasonException; - }; - - try - { - await client.ConnectAsync(TestContext.CancellationToken); - - Assert.IsTrue(client.IsConnected, "Client should be connected after ConnectAsync."); - Assert.IsTrue(connectedEventFired, "Connected event should have fired."); - Assert.IsTrue(client.NegotiatedRpcVersion >= 1, "NegotiatedRpcVersion should be >= 1."); - Assert.IsNotNull( - client.CurrentEventSubscriptions, - "CurrentEventSubscriptions should be set." - ); // Check if event subs are received - } - finally - { - if (client.IsConnected) // Only disconnect if connected - { - await client.DisconnectAsync(); - Assert.IsFalse( - client.IsConnected, - "Client should be disconnected after DisconnectAsync." - ); - Assert.IsTrue(disconnectedEventFired, "Disconnected event should have fired."); - Assert.IsNull( - disconnectReason, - "Disconnect reason should be null for graceful disconnect." - ); - } - - await client.DisposeAsync(); // Ensure disposal - } - } - - [TestMethod, TestCategory("Integration")] - [Timeout(DefaultTimeoutMs, CooperativeCancellation = true)] - public async Task GetVersion_ReturnsValidData() - { - /* OBS Setup: Requires OBS running with WebSocket enabled. */ - await using ObsWebSocketClient client = CreateClient(); // Use await using for disposal - await client.ConnectAsync(TestContext.CancellationToken); - Assert.IsTrue(client.IsConnected); - - GetVersionResponseData? version = await client.General.GetVersionAsync(); - - Assert.IsNotNull(version); - Assert.IsFalse( - string.IsNullOrWhiteSpace(version.ObsVersion), - "ObsVersion should not be empty." - ); - Assert.IsFalse( - string.IsNullOrWhiteSpace(version.ObsWebSocketVersion), - "ObsWebSocketVersion should not be empty." - ); - Assert.IsGreaterThanOrEqualTo(version.RpcVersion, 1, "RpcVersion should be >= 1."); - Assert.IsNotEmpty(version.AvailableRequests, "AvailableRequests should not be empty."); - } - - [TestMethod, TestCategory("Integration")] - [Timeout(DefaultTimeoutMs, CooperativeCancellation = true)] - public async Task SetCurrentProgramScene_ChangesScene_WhenSceneExists() - { - string sceneName = - s_testOptions.TestSceneName - ?? throw new AssertInconclusiveException("TestSceneName not configured"); - - await using ObsWebSocketClient client = CreateClient(); - await client.ConnectAsync(TestContext.CancellationToken); - Assert.IsTrue(client.IsConnected); - - // Get original scene (optional, but good practice) - GetCurrentProgramSceneResponseData? originalScene = null; - try - { - originalScene = await client.Scenes.GetCurrentProgramSceneAsync(); - Trace.WriteLine($"Original scene: {originalScene?.SceneName}"); - } - catch (Exception ex) - { - Trace.WriteLine($"Could not get original scene: {ex.Message}"); - } - - try - { - // Act: Set the scene - await client.Scenes.SetCurrentProgramSceneAsync( - new SetCurrentProgramSceneRequestData(sceneName) - ); - - // Assert: Check if the scene was actually changed - GetCurrentProgramSceneResponseData? newScene = - await client.Scenes.GetCurrentProgramSceneAsync(); - Assert.IsNotNull(newScene); - Assert.AreEqual( - sceneName, - newScene.SceneName, - $"Scene should have been changed to '{sceneName}'." - ); - } - catch (ObsWebSocketException ex) - { - // If the scene doesn't exist, OBS returns an error - if ( - ex.Message.Contains("NotFound", StringComparison.OrdinalIgnoreCase) - || ex.Message.Contains("600") - ) // 600 is ResourceNotFound code - { - Assert.Inconclusive( - $"Required scene '{sceneName}' not found in OBS. Please create it. OBS Error: {ex.Message}" - ); - } - - throw; // Re-throw other OBS errors - } - finally - { - // Attempt to restore original scene (best effort) - if (originalScene?.SceneName != null && originalScene.SceneName != sceneName) - { - try - { - Trace.WriteLine($"Attempting to restore scene to: {originalScene.SceneName}"); - await client.Scenes.SetCurrentProgramSceneAsync( - new SetCurrentProgramSceneRequestData(originalScene.SceneName) - ); - } - catch (Exception ex) - { - Trace.WriteLine($"Could not restore original scene: {ex.Message}"); - } - } - } - } - - [TestMethod, TestCategory("Integration")] - [Timeout(EventTimeoutMs, CooperativeCancellation = true)] // Allow more time for manual interaction + connection - public async Task Event_CurrentProgramSceneChanged_WhenTriggeredManually() - { - /* OBS Setup: Requires OBS running, WebSocket enabled. */ - /* Test Action: Manually change the Program scene in OBS UI *after* the test connects. */ - await using ObsWebSocketClient client = CreateClient(); - TaskCompletionSource tcs = new( - TaskCreationOptions.RunContinuationsAsynchronously - ); - string? changedToSceneName = null; - - client.CurrentProgramSceneChanged += (sender, args) => - { - Trace.WriteLine( - $"--> Integration Test: Received CurrentProgramSceneChanged event for scene '{args.EventData.SceneName}'" - ); - changedToSceneName = args.EventData.SceneName; - _ = tcs.TrySetResult(args); // Signal that the event was received - }; - - await client.ConnectAsync(TestContext.CancellationToken); - Assert.IsTrue(client.IsConnected, "Client failed to connect."); - - // Get the initial scene to know what *not* to expect initially - GetCurrentProgramSceneResponseData? initialScene = null; - try - { - initialScene = await client.Scenes.GetCurrentProgramSceneAsync(); - } - catch - { /* Ignore if fails */ - } - - Trace.WriteLine( - $"Initial scene is: {initialScene?.SceneName ?? "Unknown"}. Waiting for scene change event..." - ); - - Console.WriteLine( - $"\n>>> INTERACTION REQUIRED: Please manually change the Program scene in OBS now (within 10 seconds)!" - ); - - // Wait for the event to be fired - bool eventReceived = await Task.WhenAny(tcs.Task, Task.Delay(10000)) == tcs.Task; // 10 second timeout for manual change - - // Assert - Assert.IsTrue( - eventReceived, - "Did not receive the CurrentProgramSceneChanged event within the timeout. Did you change the scene in OBS?" - ); - Assert.IsNotNull(changedToSceneName, "Scene name in event args was null."); - - // Optional: Assert the scene name changed if you know the initial scene - if (initialScene?.SceneName != null) - { - Assert.AreNotEqual( - initialScene.SceneName, - changedToSceneName, - "Scene name should have changed from the initial scene." - ); - } - - Console.WriteLine($"<<< Event received for scene: {changedToSceneName}. Test successful."); - } - - [TestMethod, TestCategory("Integration")] - [Timeout(DefaultTimeoutMs, CooperativeCancellation = true)] - public async Task GetSceneList_ReturnsSceneStubs() - { - /* OBS Setup: Requires OBS running with WebSocket enabled and at least one scene existing (the TestSceneName). */ - await using ObsWebSocketClient client = CreateClient(); - await client.ConnectAsync(TestContext.CancellationToken); - Assert.IsTrue(client.IsConnected); - - GetSceneListResponseData? response = await client.Scenes.GetSceneListAsync(new()); - - Assert.IsNotNull(response, "GetSceneList response was null."); - Assert.IsNotEmpty(response.Scenes, "Expected at least one scene in the list."); - - // Find the test scene using the SceneStub - SceneStub? testSceneStub = response.Scenes.FirstOrDefault(s => - s.SceneName == s_testOptions.TestSceneName - ); - Assert.IsNotNull( - testSceneStub, - $"Test scene '{s_testOptions.TestSceneName}' not found in the list." - ); - Assert.IsFalse( - string.IsNullOrWhiteSpace(testSceneStub.SceneUuid), - "Scene UUID should not be empty." - ); - Assert.IsTrue(testSceneStub.SceneIndex >= 0, "Scene index should be non-negative."); - Trace.WriteLine( - $"Found Test Scene Stub: Name={testSceneStub.SceneName}, UUID={testSceneStub.SceneUuid}, Index={testSceneStub.SceneIndex}" - ); - // Check for extra data (unlikely for this basic type, but good pattern) - if (testSceneStub.ExtensionData != null && testSceneStub.ExtensionData.Count > 0) - { - Trace.WriteLine( - $" ExtensionData found: {JsonSerializer.Serialize(testSceneStub.ExtensionData)}" - ); - } - } - - [TestMethod, TestCategory("Integration")] - [Timeout(DefaultTimeoutMs, CooperativeCancellation = true)] - public async Task GetInputList_ReturnsInputStubs() - { - /* OBS Setup: Requires OBS running with WebSocket enabled and at least one input (the TestInputName). */ - await using ObsWebSocketClient client = CreateClient(); - await client.ConnectAsync(TestContext.CancellationToken); - Assert.IsTrue(client.IsConnected); - - GetInputListResponseData? response = await client.Inputs.GetInputListAsync( - new GetInputListRequestData() - ); // Empty request data - - Assert.IsNotNull(response, "GetInputList response was null."); - Assert.IsNotEmpty(response.Inputs, "Expected at least one input in the list."); - - // Find the test input - InputStub? testInputStub = response.Inputs.FirstOrDefault(i => - i.InputName == s_testOptions.TestInputName - ); - Assert.IsNotNull( - testInputStub, - $"Test input '{s_testOptions.TestInputName}' not found in the list." - ); - Assert.IsFalse( - string.IsNullOrWhiteSpace(testInputStub.InputUuid), - "Input UUID should not be empty." - ); - Assert.IsFalse( - string.IsNullOrWhiteSpace(testInputStub.InputKind), - "Input Kind should not be empty." - ); - Assert.IsFalse( - string.IsNullOrWhiteSpace(testInputStub.UnversionedInputKind), - "Unversioned Input Kind should not be empty." - ); - Trace.WriteLine( - $"Found Test Input Stub: Name={testInputStub.InputName}, UUID={testInputStub.InputUuid}, Kind={testInputStub.InputKind}" - ); - } - - [TestMethod, TestCategory("Integration")] - [Timeout(DefaultTimeoutMs, CooperativeCancellation = true)] - public async Task GetSceneItemTransform_ReturnsTransformStub() - { - /* OBS Setup: Requires OBS running with TestSceneName containing TestInputName. */ - await using ObsWebSocketClient client = CreateClient(); - await client.ConnectAsync(TestContext.CancellationToken); - Assert.IsTrue(client.IsConnected); - - // Get the scene item ID first - GetSceneItemIdResponseData? idResponse = await client.SceneItems.GetSceneItemIdAsync( - new GetSceneItemIdRequestData( - s_testOptions.TestInputName!, - sceneName: s_testOptions.TestSceneName! - ) - ); - Assert.IsNotNull( - idResponse, - $"Could not get SceneItemId for '{s_testOptions.TestInputName}' in scene '{s_testOptions.TestSceneName}'. Ensure it exists." - ); - long sceneItemId = idResponse.SceneItemId; - - // Get the transform - GetSceneItemTransformResponseData? transformResponse = - await client.SceneItems.GetSceneItemTransformAsync( - new GetSceneItemTransformRequestData( - sceneItemId, - sceneName: s_testOptions.TestSceneName! - ) - ); - - Assert.IsNotNull(transformResponse, "GetSceneItemTransform response was null."); - - // Validate some core transform properties - SceneItemTransformStub transform = transformResponse.SceneItemTransform; - Trace.WriteLine( - $"Transform for Item {sceneItemId}: Pos=({transform.PositionX},{transform.PositionY}), Scale=({transform.ScaleX},{transform.ScaleY}), Size=({transform.Width}x{transform.Height})" - ); - } - - [TestMethod, TestCategory("Integration")] - [Timeout(DefaultTimeoutMs, CooperativeCancellation = true)] - public async Task GetInputAudioTracks_ReturnsDictionary() - { - /* OBS Setup: Requires OBS running with an audio input named like 'Mic/Aux' (configure in TestAudioInputName). */ - await using ObsWebSocketClient client = CreateClient(); - await client.ConnectAsync(TestContext.CancellationToken); - Assert.IsTrue(client.IsConnected); - - GetInputAudioTracksResponseData? response; - try - { - response = await client.Inputs.GetInputAudioTracksAsync( - new GetInputAudioTracksRequestData(s_testOptions.TestAudioInputName!) - ); - } - catch (ObsWebSocketException ex) when (ex.Message.Contains("ResourceNotFound")) - { - Assert.Inconclusive( - $"Required audio input '{s_testOptions.TestAudioInputName}' not found in OBS. Error: {ex.Message}" - ); - return; - } - - Assert.IsNotNull(response, "GetInputAudioTracks response was null."); - Assert.IsNotNull(response.InputAudioTracks, "Audio tracks object was null."); - Assert.IsNotEmpty(response.InputAudioTracks, "Expected at least one audio track."); - - // Check if common tracks exist (OBS usually has 6) - for (int i = 1; i <= 6; i++) - { - Assert.IsTrue( - response.InputAudioTracks.ContainsKey(i.ToString()), - $"Track '{i}' not found in dictionary." - ); - _ = Assert.IsInstanceOfType( - response.InputAudioTracks[i.ToString()], - $"Track '{i}' value is not a boolean." - ); - Trace.WriteLine( - $"Audio Track {i}: {(response.InputAudioTracks[i.ToString()] ? "Enabled" : "Disabled")}" - ); - } - } - - [TestMethod, TestCategory("Integration")] - [Timeout(DefaultTimeoutMs, CooperativeCancellation = true)] - public async Task GetInputSettings_TextGDI_ReturnsJsonElement() - { - /* OBS Setup: Requires OBS running with TestSceneName containing TestInputName (which should be a Text GDI+ source). */ - await using ObsWebSocketClient client = CreateClient(); - await client.ConnectAsync(TestContext.CancellationToken); - Assert.IsTrue(client.IsConnected); - - GetInputSettingsResponseData? response; - try - { - response = await client.Inputs.GetInputSettingsAsync( - new GetInputSettingsRequestData(s_testOptions.TestInputName!) - ); - } - catch (ObsWebSocketException ex) when (ex.Message.Contains("ResourceNotFound")) - { - Assert.Inconclusive( - $"Required input '{s_testOptions.TestInputName}' not found in OBS. Error: {ex.Message}" - ); - return; - } - catch (ObsWebSocketException ex) when (ex.Message.Contains("InvalidInputKind")) // Might happen if the input isn't what we expect - { - Assert.Inconclusive( - $"Input '{s_testOptions.TestInputName}' is not the expected kind for this test. Error: {ex.Message}" - ); - return; - } - - Assert.IsNotNull(response, "GetInputSettings response was null."); - Assert.AreEqual( - "text_gdiplus_v3", - response.InputKind, - "Expected input kind 'text_gdiplus_v3'." - ); // Verify kind - - // Demonstrate deserializing the JsonElement - Assert.IsNotNull(response.InputSettings, "Input settings were null."); - JsonElement settingsElement = response.InputSettings.Value; - Assert.AreEqual( - JsonValueKind.Object, - settingsElement.ValueKind, - "Expected settings to be a JSON object." - ); - - // Deserialize into a known structure for Text GDI+ v2 - TextGdiPlusSettings? textSettings = settingsElement.Deserialize( - TestUtils.s_jsonSerializerOptions - ); - Assert.IsNotNull(textSettings, "Failed to deserialize settings element."); - Trace.WriteLine($"Text GDI+ Settings 'text' property: {textSettings.Text}"); - Trace.WriteLine($"Text GDI+ Settings 'font.face': {textSettings.Font?.Face}"); - } - - [TestMethod, TestCategory("Integration")] - [Timeout(DefaultTimeoutMs, CooperativeCancellation = true)] - public async Task GetSourceFilterList_ReturnsFilterStubs() - { - /* OBS Setup: Requires TestInputName source with a filter named TestFilterName. */ - await using ObsWebSocketClient client = CreateClient(); - await client.ConnectAsync(TestContext.CancellationToken); - Assert.IsTrue(client.IsConnected); - - GetSourceFilterListResponseData? response; - try - { - response = await client.Filters.GetSourceFilterListAsync( - new GetSourceFilterListRequestData(sourceName: s_testOptions.TestInputName!) - ); - } - catch (ObsWebSocketException ex) when (ex.Message.Contains("ResourceNotFound")) - { - Assert.Inconclusive( - $"Required input '{s_testOptions.TestInputName}' not found in OBS. Error: {ex.Message}" - ); - return; - } - - Assert.IsNotNull(response, "GetSourceFilterList response was null."); - - FilterStub? testFilter = response.Filters.FirstOrDefault(f => - f.FilterName == s_testOptions.TestFilterName - ); - Assert.IsNotNull( - testFilter, - $"Test filter '{s_testOptions.TestFilterName}' not found on source '{s_testOptions.TestInputName}'. Ensure it exists." - ); - Assert.IsFalse( - string.IsNullOrWhiteSpace(testFilter.FilterKind), - "Filter kind should not be empty." - ); - Trace.WriteLine( - $"Found Test Filter Stub: Name={testFilter.FilterName}, Kind={testFilter.FilterKind}, Index={testFilter.FilterIndex}, Enabled={testFilter.FilterEnabled}" - ); - if (testFilter.FilterSettings.HasValue) - { - Trace.WriteLine($" Filter Settings: {testFilter.FilterSettings.Value.GetRawText()}"); - } - - if (testFilter.ExtensionData != null && testFilter.ExtensionData.Count > 0) - { - Trace.WriteLine( - $" ExtensionData: {JsonSerializer.Serialize(testFilter.ExtensionData)}" - ); - } - } - - [TestMethod, TestCategory("Integration")] - [Timeout(DefaultTimeoutMs, CooperativeCancellation = true)] - public async Task GetTransitionList_ReturnsTransitionStubs() - { - /* OBS Setup: Requires OBS running with WebSocket enabled. */ - await using ObsWebSocketClient client = CreateClient(); - await client.ConnectAsync(TestContext.CancellationToken); - Assert.IsTrue(client.IsConnected); - - GetSceneTransitionListResponseData? response = - await client.Transitions.GetSceneTransitionListAsync(); - - Assert.IsNotNull(response, "GetSceneTransitionList response was null."); - Assert.IsNotEmpty(response.Transitions, "Expected at least one transition."); - Assert.IsFalse( - string.IsNullOrWhiteSpace(response.CurrentSceneTransitionName), - "Current transition name should not be empty." - ); - - TransitionStub? firstTransition = response.Transitions.FirstOrDefault(); - Assert.IsNotNull(firstTransition, "First transition stub was null."); - Assert.IsFalse( - string.IsNullOrWhiteSpace(firstTransition.TransitionName), - "Transition name should not be empty." - ); - Assert.IsFalse( - string.IsNullOrWhiteSpace(firstTransition.TransitionKind), - "Transition kind should not be empty." - ); - Trace.WriteLine($"Current Transition: {response.CurrentSceneTransitionName}"); - Trace.WriteLine( - $"First Transition Stub: Name={firstTransition.TransitionName}, Kind={firstTransition.TransitionKind}" - ); - } - - [TestMethod, TestCategory("Integration")] - [Timeout(DefaultTimeoutMs, CooperativeCancellation = true)] - public async Task GetOutputList_ReturnsOutputStubs() - { - /* OBS Setup: Requires OBS running with WebSocket enabled. */ - await using ObsWebSocketClient client = CreateClient(); - await client.ConnectAsync(TestContext.CancellationToken); - Assert.IsTrue(client.IsConnected); - - GetOutputListResponseData? response = await client.Outputs.GetOutputListAsync(); - - Assert.IsNotNull(response, "GetOutputList response was null."); - Assert.IsNotEmpty( - response.Outputs, - "Expected at least one output (e.g., Simple Output or Advanced)." - ); - - OutputStub? firstOutput = response.Outputs.FirstOrDefault(); - Assert.IsNotNull(firstOutput, "First output stub was null."); - Assert.IsFalse( - string.IsNullOrWhiteSpace(firstOutput.OutputName), - "Output name should not be empty." - ); - Assert.IsFalse( - string.IsNullOrWhiteSpace(firstOutput.OutputKind), - "Output kind should not be empty." - ); - // Note: Width/Height/Settings might be null depending on the output type and state - Trace.WriteLine( - $"First Output Stub: Name={firstOutput.OutputName}, Kind={firstOutput.OutputKind}, Active={firstOutput.OutputActive}" - ); - } - - [TestMethod, TestCategory("Integration")] - [Timeout(DefaultTimeoutMs, CooperativeCancellation = true)] - public async Task GetMonitorList_ReturnsMonitorStubs() - { - /* OBS Setup: Requires OBS running with WebSocket enabled. Result depends on connected monitors. */ - await using ObsWebSocketClient client = CreateClient(); - await client.ConnectAsync(TestContext.CancellationToken); - Assert.IsTrue(client.IsConnected); - - GetMonitorListResponseData? response = await client.Ui.GetMonitorListAsync(); - - Assert.IsNotNull(response, "GetMonitorList response was null."); - - // Cannot assert count > 0 as user might have no monitors, but list should exist. - if (response.Monitors.Count > 0) - { - MonitorStub? firstMonitor = response.Monitors.FirstOrDefault(); - Assert.IsNotNull(firstMonitor, "First monitor stub was null if list is not empty."); - Assert.IsFalse( - string.IsNullOrWhiteSpace(firstMonitor.MonitorName), - "Monitor name should not be empty." - ); - Trace.WriteLine( - $"First Monitor Stub: Name={firstMonitor.MonitorName}, Index={firstMonitor.MonitorIndex}, Res=({firstMonitor.MonitorWidth}x{firstMonitor.MonitorHeight})" - ); - } - else - { - Trace.WriteLine("No monitors detected."); - } - } - - [TestMethod, TestCategory("Integration")] - [Timeout(EventTimeoutMs, CooperativeCancellation = true)] // Allow more time for manual scene change - public async Task Event_SceneListChanged_ReceivesStubList() - { - /* OBS Setup: Requires OBS running with WebSocket enabled. */ - /* Test Action: Manually create OR delete a scene in OBS UI *after* the test connects. */ - await using ObsWebSocketClient client = CreateClient(); - TaskCompletionSource tcs = new( - TaskCreationOptions.RunContinuationsAsynchronously - ); - SceneListChangedEventArgs? receivedArgs = null; - - client.SceneListChanged += (sender, args) => - { - Trace.WriteLine($"--> Integration Test: Received SceneListChanged event."); - receivedArgs = args; - _ = tcs.TrySetResult(args); // Signal that the event was received - }; - - await client.ConnectAsync(TestContext.CancellationToken); - Assert.IsTrue(client.IsConnected, "Client failed to connect."); - - Console.WriteLine( - $"\n>>> INTERACTION REQUIRED: Please manually CREATE or DELETE a scene in OBS now (within 10 seconds)!" - ); - - // Wait for the event to be fired - bool eventReceived = await Task.WhenAny(tcs.Task, Task.Delay(10000)) == tcs.Task; // 10 second timeout - - // Assert - Assert.IsTrue( - eventReceived, - "Did not receive the SceneListChanged event within the timeout. Did you create/delete a scene in OBS?" - ); - Assert.IsNotNull(receivedArgs, "Received event arguments were null."); - Assert.IsNotNull(receivedArgs.EventData.Scenes, "EventData.Scenes list was null."); - - if (receivedArgs.EventData.Scenes.Count > 0) - { - SceneStub firstStub = receivedArgs.EventData.Scenes[0]; - Assert.IsFalse( - string.IsNullOrWhiteSpace(firstStub.SceneName), - "First scene stub's name should not be empty." - ); - Assert.IsFalse( - string.IsNullOrWhiteSpace(firstStub.SceneUuid), - "First scene stub's UUID should not be empty." - ); - Assert.IsTrue( - firstStub.SceneIndex >= 0, - "First scene stub's index should be non-negative." - ); - Trace.WriteLine( - $"SceneListChanged event verified. First scene: Name='{firstStub.SceneName}', Index={firstStub.SceneIndex}" - ); - } - else - { - Trace.WriteLine("SceneListChanged event verified with an empty scene list."); - } - } - - [TestMethod, TestCategory("Integration")] - [Timeout(DefaultTimeoutMs, CooperativeCancellation = true)] - public async Task GetInputDefaultSettings_ReturnsJsonElement_CanDeserialize() - { - /* OBS Setup: Requires OBS running with WebSocket enabled. */ - string inputKind = "text_gdiplus_v3"; // Use a known input kind - await using ObsWebSocketClient client = CreateClient(); - await client.ConnectAsync(TestContext.CancellationToken); - Assert.IsTrue(client.IsConnected); - - GetInputDefaultSettingsResponseData? response; - try - { - response = await client.Inputs.GetInputDefaultSettingsAsync( - new GetInputDefaultSettingsRequestData(inputKind) - ); - } - catch (ObsWebSocketException ex) when (ex.Message.Contains("InvalidInputKind")) - { - Assert.Inconclusive( - $"Input kind '{inputKind}' not recognized by OBS. Error: {ex.Message}" - ); - return; - } - catch (ObsWebSocketException ex) when (ex.Message.Contains("ResourceNotFound")) // Might happen if kind doesn't exist? - { - Assert.Inconclusive($"Input kind '{inputKind}' not found by OBS. Error: {ex.Message}"); - return; - } - - Assert.IsNotNull(response, "GetInputDefaultSettings response was null."); - - Assert.IsNotNull(response.DefaultInputSettings, "Default input settings were null."); - JsonElement settingsElement = response.DefaultInputSettings.Value; - Assert.AreEqual( - JsonValueKind.Object, - settingsElement.ValueKind, - "Expected default settings to be a JSON object." - ); - - // Demonstrate deserializing the JsonElement into a Dictionary - Dictionary? defaultSettingsDict = settingsElement.Deserialize< - Dictionary - >(TestUtils.s_jsonSerializerOptions); - Assert.IsNotNull( - defaultSettingsDict, - "Failed to deserialize default settings JsonElement." - ); - - // Assert some known default properties for text_gdiplus_v3 exist - Assert.IsTrue(defaultSettingsDict.ContainsKey("font"), "Expected 'font' default setting."); - Assert.AreEqual( - JsonValueKind.Object, - defaultSettingsDict["font"].ValueKind, - "'font' setting should be an object." - ); - Trace.WriteLine( - $"Successfully deserialized default settings for '{inputKind}'. Found 'text' and 'font'." - ); - Trace.WriteLine($"Raw Default Settings JSON: {settingsElement.GetRawText()}"); - } - - // --- Helper Record for Text GDI+ settings deserialization --- - private record TextGdiPlusSettings( - [property: JsonPropertyName("text")] string? Text, - [property: JsonPropertyName("font")] FontStub? Font - ); - - private record FontStub( - [property: JsonPropertyName("face")] string? Face, - [property: JsonPropertyName("size")] int? Size, - [property: JsonPropertyName("style")] string? Style, - [property: JsonPropertyName("flags")] int? Flags - ); -} diff --git a/ObsWebSocket.Tests/ObsWebSocketDiTests.cs b/ObsWebSocket.Tests/ObsWebSocketDiTests.cs index 40213f0..06aa7ad 100644 --- a/ObsWebSocket.Tests/ObsWebSocketDiTests.cs +++ b/ObsWebSocket.Tests/ObsWebSocketDiTests.cs @@ -33,7 +33,7 @@ private static ServiceCollection CreateServiceCollectionWithLogging() /// with the correct lifetimes (Singleton). /// [TestMethod] - public void AddObsWebSocketClient_RegistersServicesCorrectly() + public void AddObsWebSocketClient_Default_RegistersServices() { // Arrange ServiceCollection services = CreateServiceCollectionWithLogging(); diff --git a/ObsWebSocket.Tests/PayloadShapeTests.cs b/ObsWebSocket.Tests/PayloadShapeTests.cs index 1c9a328..d43ce51 100644 --- a/ObsWebSocket.Tests/PayloadShapeTests.cs +++ b/ObsWebSocket.Tests/PayloadShapeTests.cs @@ -48,7 +48,7 @@ public void Json_ReadingAPayloadAsTheWrongRecord_Throws() } [TestMethod] - public void ReadingAPayloadAsItsOwnRecord_StillWorks() + public void GetData_OwnRecordType_Reads() { GetVersionResponseData version = TestUtils.SampleVersion(); byte[] packed = MessagePackSerializer.Serialize( @@ -63,7 +63,7 @@ public void ReadingAPayloadAsItsOwnRecord_StillWorks() } [TestMethod] - public void RecordsWithAnIdenticalShape_AreStillInterchangeable() + public void GetData_IdenticallyShapedRecord_Reads() { // GetInputMute and ToggleInputMute are both a single inputMuted field, so reading one as // the other gives the right value. The check must not reject that. diff --git a/ObsWebSocket.Tests/SerializerBehaviorTests.cs b/ObsWebSocket.Tests/SerializerBehaviorTests.cs index 18cbda1..493f443 100644 --- a/ObsWebSocket.Tests/SerializerBehaviorTests.cs +++ b/ObsWebSocket.Tests/SerializerBehaviorTests.cs @@ -545,7 +545,7 @@ public void MsgPackSerializer_SerializeThenDeserialize_FilterPayload_RoundTripsW } [TestMethod] - public void MsgPackResolver_CoversGeneratedAndCoreProtocolTypes() + public void MsgPackResolver_ProtocolTypes_AllCovered() { Type[] allTypes = typeof(ObsWebSocketClient).Assembly.GetTypes(); List requiredTypes = []; diff --git a/ObsWebSocket.Tests/SerializerEdgeTests.cs b/ObsWebSocket.Tests/SerializerEdgeTests.cs new file mode 100644 index 0000000..d366fab --- /dev/null +++ b/ObsWebSocket.Tests/SerializerEdgeTests.cs @@ -0,0 +1,275 @@ +using System.Text; +using System.Text.Json; +using MessagePack; +using Microsoft.Extensions.Logging; +using ObsWebSocket.Core; +using ObsWebSocket.Core.Protocol; +using ObsWebSocket.Core.Protocol.Generated; +using ObsWebSocket.Core.Serialization; + +namespace ObsWebSocket.Tests; + +/// +/// The serializer paths the fake server cannot reach: streams, failed writes, fields a newer OBS +/// adds, and the diagnostics that only run with tracing on. +/// +[TestClass] +public sealed class SerializerEdgeTests +{ + /// A logger with every level on, so the diagnostics behind level checks run. + private sealed class RecordingLogger : ILogger + { + public List Messages { get; } = []; + + public IDisposable? BeginScope(TState state) + where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter + ) => Messages.Add(formatter(state, exception)); + } + + /// A stream that cannot seek, like a network stream. + private sealed class ForwardOnlyStream(byte[] data) : MemoryStream(data) + { + public override bool CanSeek => false; + } + + /// A payload type neither serializer has metadata for. + private sealed record Unregistered(int Value); + + public TestContext TestContext { get; set; } = null!; + + #region JSON + + [TestMethod] + public async Task JsonDeserializeAsync_NonSeekableStream_Reads() + { + RecordingLogger logger = new(); + JsonMessageSerializer json = new(logger); + byte[] frame = Encoding.UTF8.GetBytes("""{"op":2,"d":{"negotiatedRpcVersion":1}}"""); + + object? message = await json.DeserializeAsync( + new ForwardOnlyStream(frame), + TestContext.CancellationToken + ); + + Assert.AreEqual(WebSocketOpCode.Identified, ((IncomingMessage)message!).Op); + Assert.IsNotEmpty(logger.Messages, "tracing is on, so the parse is logged"); + } + + [TestMethod] + public async Task JsonDeserializeAsync_EmptyOrUnreadable_ReturnsNull() + { + RecordingLogger logger = new(); + JsonMessageSerializer json = new(logger); + + Assert.IsNull( + await json.DeserializeAsync(new MemoryStream(), TestContext.CancellationToken) + ); + Assert.IsNull( + await json.DeserializeAsync(ReadOnlyMemory.Empty, TestContext.CancellationToken) + ); + + string longGarbage = "{" + new string('x', 2048); + Assert.IsNull( + await json.DeserializeAsync( + new MemoryStream(Encoding.UTF8.GetBytes(longGarbage)), + TestContext.CancellationToken + ) + ); + Assert.IsNull( + await json.DeserializeAsync( + Encoding.UTF8.GetBytes(longGarbage), + TestContext.CancellationToken + ) + ); + Assert.IsNull( + await json.DeserializeAsync( + new MemoryStream("null"u8.ToArray()), + TestContext.CancellationToken + ) + ); + Assert.IsNull( + await json.DeserializeAsync("null"u8.ToArray(), TestContext.CancellationToken) + ); + + // The raw text is kept for the log, but cut short rather than pasted whole. + Assert.IsTrue(logger.Messages.Any(m => m.Contains("...", StringComparison.Ordinal))); + } + + [TestMethod] + public async Task JsonSerializeAsync_UnregisteredType_ThrowsSerialization() + { + JsonMessageSerializer json = new(new RecordingLogger()); + + _ = await Assert.ThrowsExactlyAsync(() => + json.SerializeAsync( + new OutgoingMessage(WebSocketOpCode.Request, new Unregistered(1)), + TestContext.CancellationToken + ) + ); + } + + [TestMethod] + public void JsonDeserializeValuePayload_Unreadable_Reports() + { + RecordingLogger logger = new(); + JsonMessageSerializer json = new(logger); + JsonElement text = JsonDocument.Parse("\"not a number\"").RootElement.Clone(); + + Assert.IsNull(json.DeserializeValuePayload("not an element")); + Assert.IsNull(json.DeserializeValuePayload(default(JsonElement))); + _ = Assert.ThrowsExactly(() => + json.DeserializeValuePayload(text) + ); + + Assert.IsFalse(json.TryDeserializeValuePayload(text, out int? value)); + Assert.IsNull(value); + Assert.IsTrue( + json.TryDeserializeValuePayload( + JsonDocument.Parse("42").RootElement.Clone(), + out int? read + ) + ); + Assert.AreEqual(42, read); + } + + [TestMethod] + public void JsonDeserializeValuePayload_LargePayload_TruncatesMessage() + { + JsonMessageSerializer json = new(new RecordingLogger()); + JsonElement large = JsonDocument + .Parse($$"""{"value":"{{new string('x', 4096)}}"}""") + .RootElement.Clone(); + + ObsWebSocketSerializationException error = + Assert.ThrowsExactly(() => + json.DeserializeValuePayload(large) + ); + + Assert.IsLessThan(1024, error.Message.Length); + Assert.EndsWith("...", error.Message); + } + + #endregion + + #region MessagePack + + private static MsgPackMessageSerializer MsgPack( + RecordingLogger? logger = null + ) => new(logger ?? new RecordingLogger()); + + private static async Task>> ReadEnvelopeAsync( + MsgPackMessageSerializer serializer, + string json, + CancellationToken cancellationToken + ) => + (IncomingMessage>) + ( + await serializer.DeserializeAsync( + MessagePackSerializer.ConvertFromJson(json), + cancellationToken + ) + )!; + + [TestMethod] + public async Task MsgPackTryDeserializePayload_UnknownFields_SkipsThem() + { + RecordingLogger logger = new(); + MsgPackMessageSerializer serializer = MsgPack(logger); + CancellationToken token = TestContext.CancellationToken; + + IncomingMessage> ev = await ReadEnvelopeAsync( + serializer, + """{"op":5,"future":true,"d":{"eventType":"StudioModeStateChanged","eventIntent":1,"future":[1,2],"eventData":{"studioModeEnabled":true}}}""", + token + ); + Assert.IsTrue( + serializer.TryDeserializePayload(ev.D, out EventPayloadBase? eventPayload) + ); + Assert.AreEqual("StudioModeStateChanged", eventPayload!.EventType); + + IncomingMessage> response = await ReadEnvelopeAsync( + serializer, + """{"op":7,"d":{"requestType":"GetVersion","requestId":"1","future":{"a":1},"requestStatus":{"result":true,"code":100},"responseData":{}}}""", + token + ); + Assert.IsTrue( + serializer.TryDeserializePayload( + response.D, + out RequestResponsePayload? responsePayload + ) + ); + Assert.AreEqual("1", responsePayload!.RequestId); + + IncomingMessage> batch = await ReadEnvelopeAsync( + serializer, + """{"op":9,"d":{"requestId":"b","future":"x","results":[{"requestType":"GetVersion","requestId":"b_0","requestStatus":{"result":true,"code":100}}]}}""", + token + ); + Assert.IsTrue( + serializer.TryDeserializePayload( + batch.D, + out RequestBatchResponsePayload? batchPayload + ) + ); + Assert.HasCount(1, batchPayload!.Results); + + Assert.IsNotEmpty(logger.Messages, "tracing is on, so each parse is logged"); + } + + [TestMethod] + public async Task MsgPackDeserializeAsync_Unreadable_ReturnsNull() + { + MsgPackMessageSerializer serializer = MsgPack(); + + Assert.IsNull( + await serializer.DeserializeAsync(new byte[] { 0xC1 }, TestContext.CancellationToken) + ); + Assert.IsNull( + await serializer.DeserializeAsync( + ReadOnlyMemory.Empty, + TestContext.CancellationToken + ) + ); + } + + [TestMethod] + public async Task MsgPackSerializeAsync_UnregisteredType_ThrowsSerialization() + { + MsgPackMessageSerializer serializer = MsgPack(); + + _ = await Assert.ThrowsExactlyAsync(() => + serializer.SerializeAsync( + new OutgoingMessage(WebSocketOpCode.Request, new Unregistered(1)), + TestContext.CancellationToken + ) + ); + } + + [TestMethod] + public void MsgPackTryDeserializeValuePayload_Unreadable_ReturnsFalse() + { + MsgPackMessageSerializer serializer = MsgPack(); + ReadOnlyMemory text = MessagePackSerializer.ConvertFromJson("\"not a number\""); + + Assert.IsFalse(serializer.TryDeserializeValuePayload(text, out int? value)); + Assert.IsNull(value); + Assert.IsTrue( + serializer.TryDeserializeValuePayload( + (ReadOnlyMemory)MessagePackSerializer.ConvertFromJson("42"), + out int? read + ) + ); + Assert.AreEqual(42, read); + } + + #endregion +} diff --git a/ObsWebSocket.Tests/SerializerFailurePathTests.cs b/ObsWebSocket.Tests/SerializerFailurePathTests.cs new file mode 100644 index 0000000..6caee39 --- /dev/null +++ b/ObsWebSocket.Tests/SerializerFailurePathTests.cs @@ -0,0 +1,95 @@ +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Logging.Abstractions; +using ObsWebSocket.Core; +using ObsWebSocket.Core.Protocol; +using ObsWebSocket.Core.Protocol.Responses; +using ObsWebSocket.Core.Serialization; + +namespace ObsWebSocket.Tests; + +/// +/// The failure paths of both serializers: a payload that is absent, the wrong shape, or not +/// readable at all. The receive loop depends on these reporting rather than throwing. +/// +[TestClass] +public sealed class SerializerFailurePathTests +{ + private static JsonMessageSerializer Json() => new(NullLogger.Instance); + + private static MsgPackMessageSerializer MsgPack() => + new(NullLogger.Instance); + + [TestMethod] + public void JsonDeserializePayload_Null_ReturnsNull() => + Assert.IsNull(Json().DeserializePayload(null)); + + [TestMethod] + public void JsonDeserializePayload_WrongRuntimeType_ReturnsNull() => + Assert.IsNull(Json().DeserializePayload("not an element")); + + [TestMethod] + public void JsonDeserializeValuePayload_Null_ReturnsNull() => + Assert.IsNull(Json().DeserializeValuePayload(null)); + + [TestMethod] + public void JsonTryDeserializePayload_WrongShape_ReturnsFalse() + { + JsonElement element = JsonDocument.Parse("""{"obsVersion":5}""").RootElement.Clone(); + + bool read = Json().TryDeserializePayload(element, out GetVersionResponseData? payload); + + Assert.IsFalse(read); + Assert.IsNull(payload); + } + + [TestMethod] + public void JsonDeserializePayload_WrongShape_Throws() => + Assert.ThrowsExactly(() => + Json() + .DeserializePayload( + JsonDocument.Parse("""{"obsVersion":5}""").RootElement.Clone() + ) + ); + + [TestMethod] + public async Task JsonDeserializeAsync_Garbage_ReturnsNull() + { + ReadOnlyMemory garbage = Encoding.UTF8.GetBytes("{ this is not json"); + + Assert.IsNull(await Json().DeserializeAsync(garbage, TestContext.CancellationToken)); + } + + [TestMethod] + public void MsgPackDeserializePayload_Null_ReturnsNull() => + Assert.IsNull(MsgPack().DeserializePayload(null)); + + [TestMethod] + public void MsgPackTryDeserializePayload_WrongShape_ReturnsFalse() + { + ReadOnlyMemory garbage = new byte[] { 0xC1 }; + + bool read = MsgPack().TryDeserializePayload(garbage, out GetVersionResponseData? payload); + + Assert.IsFalse(read); + Assert.IsNull(payload); + } + + [TestMethod] + public async Task MsgPackDeserializeAsync_Garbage_ReturnsNull() + { + ReadOnlyMemory garbage = new byte[] { 0xC1, 0xC1 }; + + Assert.IsNull(await MsgPack().DeserializeAsync(garbage, TestContext.CancellationToken)); + } + + [TestMethod] + public void ProtocolSubProtocol_BothSerializers_ReportTheirName() + { + Assert.AreEqual("obswebsocket.json", Json().ProtocolSubProtocol); + Assert.AreEqual("obswebsocket.msgpack", MsgPack().ProtocolSubProtocol); + } + + /// The context MSTest assigns, used for cancellation. + public TestContext TestContext { get; set; } = null!; +} diff --git a/ObsWebSocket.Tests/SettingsRoundTripTests.cs b/ObsWebSocket.Tests/SettingsRoundTripTests.cs new file mode 100644 index 0000000..52adaad --- /dev/null +++ b/ObsWebSocket.Tests/SettingsRoundTripTests.cs @@ -0,0 +1,100 @@ +using System.Reflection; +using System.Text.Json; +using MessagePack; +using ObsWebSocket.Core.Protocol.Common.FilterSettings; +using ObsWebSocket.Core.Protocol.Common.InputSettings; + +namespace ObsWebSocket.Tests; + +/// +/// The settings records exist to cross the wire, so every one of them is round tripped on both +/// formats rather than trusting the shapes by inspection. +/// +[TestClass] +public sealed class SettingsRoundTripTests +{ + private static IEnumerable SettingsTypes => + typeof(GainFilterSettings) + .Assembly.GetExportedTypes() + .Where(type => + type is { IsClass: true, IsAbstract: false } + && type.Name.EndsWith("Settings", StringComparison.Ordinal) + && type.Namespace is not null + && ( + type.Namespace.EndsWith(".FilterSettings", StringComparison.Ordinal) + || type.Namespace.EndsWith(".InputSettings", StringComparison.Ordinal) + ) + ); + + public static IEnumerable AllSettingsTypes => + SettingsTypes.Select(type => new object[] { type }); + + [TestMethod] + public void SettingsTypes_Reflected_AllDiscovered() => + Assert.IsGreaterThan(20, SettingsTypes.Count()); + + [TestMethod] + [DynamicData(nameof(AllSettingsTypes))] + public void SettingsTypes_Json_RoundTrip(Type settingsType) + { + object instance = CreateDefault(settingsType); + + string json = JsonSerializer.Serialize(instance, settingsType); + object? restored = JsonSerializer.Deserialize(json, settingsType); + + Assert.IsNotNull(restored); + Assert.AreEqual(instance, restored); + Assert.IsNotEmpty(instance.ToString()!); + Assert.AreEqual(instance.GetHashCode(), restored.GetHashCode()); + } + + [TestMethod] + [DynamicData(nameof(AllSettingsTypes))] + public void SettingsTypes_MessagePack_RoundTrip(Type settingsType) + { + object instance = CreateDefault(settingsType); + + byte[] packed = MessagePackSerializer.Serialize( + settingsType, + instance, + MessagePackSerializerOptions.Standard.WithResolver( + MessagePack.Resolvers.ContractlessStandardResolver.Instance + ) + ); + object? restored = MessagePackSerializer.Deserialize( + settingsType, + packed, + MessagePackSerializerOptions.Standard.WithResolver( + MessagePack.Resolvers.ContractlessStandardResolver.Instance + ) + ); + + Assert.IsNotNull(restored); + } + + /// + /// Builds an instance from the longest constructor, leaving every optional parameter at its + /// default. The settings records are positional and all optional, so this reaches them all. + /// + private static object CreateDefault(Type settingsType) + { + ConstructorInfo constructor = settingsType + .GetConstructors() + .OrderByDescending(candidate => candidate.GetParameters().Length) + .First(); + + object?[] arguments = + [ + .. constructor + .GetParameters() + .Select(parameter => + parameter.HasDefaultValue ? parameter.DefaultValue + : parameter.ParameterType.IsValueType + ? Activator.CreateInstance(parameter.ParameterType) + : null + ), + ]; + + return constructor.Invoke(arguments); + } +} diff --git a/ObsWebSocket.Tests/StubArrayTests.cs b/ObsWebSocket.Tests/StubArrayTests.cs index 35f2bc3..ced2f3f 100644 --- a/ObsWebSocket.Tests/StubArrayTests.cs +++ b/ObsWebSocket.Tests/StubArrayTests.cs @@ -140,7 +140,7 @@ public void InputVolumeMeters_ReadOverJson_KeepsPerChannelLevels() } [TestMethod] - public void InputVolumeMeters_RoundTripsOverMsgPack() + public void InputVolumeMeters_MsgPack_RoundTrips() { InputVolumeMetersPayload original = new() { @@ -203,7 +203,7 @@ public void SceneItemListReindexed_ReadOverJson_KeepsIdAndIndex() } [TestMethod] - public void SceneItemListReindexed_RoundTripsOverMsgPack() + public void SceneItemListReindexed_MsgPack_RoundTrips() { SceneItemListReindexedPayload original = new() { @@ -432,7 +432,7 @@ public void SceneItemTransform_AlignmentAboveInt32Max_ReadsOverJson() /// transport that cannot read the message. /// [TestMethod] - public void JsonElementList_HasAMsgPackFormatter() + public void JsonElementList_MsgPack_HasFormatter() { using JsonDocument doc = JsonDocument.Parse("""{"a":1}"""); List original = [doc.RootElement.Clone()]; diff --git a/ObsWebSocket.Tests/TimeProviderTests.cs b/ObsWebSocket.Tests/TimeProviderTests.cs index 027e79d..afd1d0d 100644 --- a/ObsWebSocket.Tests/TimeProviderTests.cs +++ b/ObsWebSocket.Tests/TimeProviderTests.cs @@ -15,7 +15,7 @@ namespace ObsWebSocket.Tests; public sealed class TimeProviderTests { [TestMethod] - public async Task RequestTimeout_ElapsesOnlyWhenTheProviderAdvances() + public async Task RequestTimeout_FakeClock_ElapsesOnlyWhenAdvanced() { FakeTimeProvider time = new(); ( diff --git a/ObsWebSocket.Tests/WireEnumTests.cs b/ObsWebSocket.Tests/WireEnumTests.cs index 31b0bee..13e0f93 100644 --- a/ObsWebSocket.Tests/WireEnumTests.cs +++ b/ObsWebSocket.Tests/WireEnumTests.cs @@ -16,7 +16,7 @@ namespace ObsWebSocket.Tests; public sealed class WireEnumTests { [TestMethod] - public void Json_WritesTheProtocolStringNotTheMemberName() + public void JsonWrite_WireEnum_UsesProtocolString() { TriggerMediaInputActionRequestData request = new( mediaAction: MediaInputAction.Stop, @@ -33,7 +33,7 @@ public void Json_WritesTheProtocolStringNotTheMemberName() } [TestMethod] - public void MsgPack_WritesTheProtocolStringNotTheOrdinal() + public void MsgPackWrite_WireEnum_UsesProtocolString() { TriggerMediaInputActionRequestData request = new( mediaAction: MediaInputAction.Stop, @@ -55,7 +55,7 @@ public void MsgPack_WritesTheProtocolStringNotTheOrdinal() } [TestMethod] - public void MsgPack_ReadsTheProtocolStringBackIntoTheEnum() + public void MsgPackRead_ProtocolString_ReturnsEnum() { StreamStateChangedPayload payload = new( outputActive: true, @@ -81,7 +81,7 @@ public void MsgPack_ReadsTheProtocolStringBackIntoTheEnum() } [TestMethod] - public void UnrecognisedWireValue_FallsBackToTheZeroMemberRatherThanThrowing() + public void Read_UnrecognisedWireValue_FallsBackToZeroMember() { // A state added by a newer OBS must not fail the whole message. string json = """{"outputActive":true,"outputState":"OBS_WEBSOCKET_OUTPUT_FUTURE_STATE"}"""; diff --git a/ObsWebSocket.Tests/obs.runsettings b/ObsWebSocket.Tests/obs.runsettings new file mode 100644 index 0000000..a188c65 --- /dev/null +++ b/ObsWebSocket.Tests/obs.runsettings @@ -0,0 +1,16 @@ + + + + + + + + + diff --git a/README.md b/README.md index f9551f1..6fbd373 100644 --- a/README.md +++ b/README.md @@ -732,26 +732,14 @@ rather than only under unit tests. ## Example app -`ObsWebSocket.Example` is a host-based sample with configuration and DI. - -- Interactive mode: a command loop, listed by `help`. -- Validation mode: `ObsWebSocket.Example run-transport-tests` runs the same checks over JSON and - MessagePack against a scene, input and filter it creates and removes itself. - -The validation run covers the settings helpers, event streams, `WaitForEventAsync`, the batch -builder, the raw path, typed enums, screenshots and handles. It also calls every read request and -every safely sendable write request, and fails on any response it cannot deserialize. - -It is a single self-contained command, prints a verdict and exits non-zero if any check failed, so -it can be run by hand or used as a gate. Point it at an OBS with configuration, environment -variables or command-line arguments, in that order of precedence: - -```bash -Obs__ServerUri=ws://localhost:4455 Obs__Password=secret \ - dotnet run --project ObsWebSocket.Example -- run-transport-tests -``` - -The `Live OBS validation` workflow runs exactly this against an OBS it installs and starts itself. +`ObsWebSocket.Example` is a host-based sample with configuration and DI: an interactive command +loop, listed by `help`. + +The checks that prove the client against a real OBS live in the test project instead. The +integration tests call every read request and every safely sendable write request over JSON and +MessagePack, and exercise the settings helpers, event streams, `WaitForEventAsync`, the batch +builder, typed enums, screenshots and handles. See `CONTRIBUTING.md` for how to run them; the +`Full suite against live OBS` workflow runs them against an OBS it installs and starts itself. ## Native AOT diff --git a/coverage.settings.xml b/coverage.settings.xml index 2fdfabb..c35683b 100644 --- a/coverage.settings.xml +++ b/coverage.settings.xml @@ -1,16 +1,23 @@ - .*\.Generated\.dll$ .*\.Tests\.dll$ + + + .*\.g\.cs$ + .*\obj\.* + + ^System\.Diagnostics\.CodeAnalysis\.ExcludeFromCodeCoverageAttribute$