diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index 54069934..4433b164 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -3,14 +3,14 @@ "isRoot": true, "tools": { "dotnet-stryker": { - "version": "4.9.2", + "version": "4.11.0", "commands": [ "dotnet-stryker" ], "rollForward": false }, "dotnet-reportgenerator-globaltool": { - "version": "5.4.11", + "version": "5.5.1", "commands": [ "reportgenerator" ], diff --git a/AtemSharp.CodeGenerators/Serialization/SerializedCommandGenerator.cs b/AtemSharp.CodeGenerators/Serialization/SerializedCommandGenerator.cs index 41915131..0ac8ace7 100644 --- a/AtemSharp.CodeGenerators/Serialization/SerializedCommandGenerator.cs +++ b/AtemSharp.CodeGenerators/Serialization/SerializedCommandGenerator.cs @@ -60,6 +60,26 @@ protected override void ProcessClass(SourceProductionContext spc, INamedTypeSymb "using System.CodeDom.Compiler;" }.Concat(fields.Select(x => x!.NamespaceCode)).Distinct(); + var mergeCode = $$""" + {{Helpers.CodeGeneratorAttribute}} + internal override bool TryMergeTo(SerializedCommand other) { + if (other is not {{className}} target) { + return false; + } + + {{string.Join("\n", fields.Select(x => x!.MergeComparison))}} + + {{string.Join("\n", fields.Select(x => x!.MergeCode))}} + + return true; + } + """; + + if (classSymbol.GetMembers().OfType().Any(x => x.Name == "TryMergeTo")) + { + mergeCode = string.Empty; + } + var fileContent = $$""" {{string.Join("\n", namespaces)}} @@ -88,6 +108,8 @@ public override byte[] Serialize(ProtocolVersion version) { return buffer; } + + {{mergeCode}} } #nullable restore @@ -105,18 +127,24 @@ public override byte[] Serialize(ProtocolVersion version) { } var propertyCode = GetPropertyCode(f); + var propertyName = Helpers.GetPropertyName(f); return new SerializedField { SerializationCode = serializationCode, PropertyCode = propertyCode, - NamespaceCode = Helpers.CreateNamespaceCode(f) + NamespaceCode = Helpers.CreateNamespaceCode(f), + MergeComparison = !HasProperty(f) ? $"if (target.{f.Name} != {f.Name}) {{ return false; }}" : string.Empty, + MergeCode = HasProperty(f) ? $"if (_{f.Name}_isDirty) {{ target.{propertyName} = {propertyName}; }}" : string.Empty, }; } + private static bool HasProperty(IFieldSymbol f) + => !f.GetAttributes().Any(a => a.AttributeClass?.Name == "NoPropertyAttribute"); + private static string GetPropertyCode(IFieldSymbol f) { - if (f.GetAttributes().Any(a => a.AttributeClass?.Name == "NoPropertyAttribute")) + if (!HasProperty(f)) { return string.Empty; } @@ -130,17 +158,21 @@ private static string GetPropertyCode(IFieldSymbol f) var docComment = Helpers.GetFieldMsDocComment(f); var visibility = f.GetAttributes().Any(a => a.AttributeClass?.Name == "InternalPropertyAttribute") ? "internal" : "public"; - var setter = f.IsReadOnly + var hasSetter = f.IsReadOnly || f.GetAttributes().Any(a => a.AttributeClass?.Name == "ReadOnlyAttribute"); + var setter = hasSetter ? string.Empty : $$""" set { {{validationCode}} {{f.Name}} = value; {{flagCode}} + _{{f.Name}}_isDirty = true; } """; return $$""" + {{ ( hasSetter ? string.Empty : $"#pragma warning disable CS0414\nprivate bool _{f.Name}_isDirty;\n#pragma warning restore CS0414" ) }} + {{docComment}} {{Helpers.CodeGeneratorAttribute}} {{visibility}} {{fieldType}} {{propertyName}} diff --git a/AtemSharp.CodeGenerators/Serialization/SerializedField.cs b/AtemSharp.CodeGenerators/Serialization/SerializedField.cs index 9911f217..aad6bae0 100644 --- a/AtemSharp.CodeGenerators/Serialization/SerializedField.cs +++ b/AtemSharp.CodeGenerators/Serialization/SerializedField.cs @@ -5,5 +5,7 @@ public class SerializedField public string PropertyCode { get; set; } = string.Empty; public string SerializationCode { get; set; } = string.Empty; public string NamespaceCode { get; set; } = string.Empty; + public string MergeComparison { get; set; } = string.Empty; + public string MergeCode { get; set; } = string.Empty; } } diff --git a/AtemSharp.CodeGenerators/State/StateGenerator.cs b/AtemSharp.CodeGenerators/State/StateGenerator.cs index fe7b358d..4515f270 100644 --- a/AtemSharp.CodeGenerators/State/StateGenerator.cs +++ b/AtemSharp.CodeGenerators/State/StateGenerator.cs @@ -9,27 +9,50 @@ namespace AtemSharp.CodeGenerators.State; public class StateGenerator : CodeGeneratorBase { private static readonly string[] HardCodedNamespaces = - { - "System.ComponentModel", - "System.Runtime.CompilerServices", - "System.CodeDom.Compiler" - }; + { + "System.ComponentModel", + "System.Runtime.CompilerServices", + "System.CodeDom.Compiler" + }; protected override void ProcessClass(SourceProductionContext spc, INamedTypeSymbol classSymbol, ClassDeclarationSyntax classDecl) { var fields = classSymbol.GetMembers() .OfType() .Where(f => f.AssociatedSymbol is null) - .Where(f => !f.GetAttributes().Any(a => a.AttributeClass?.Name is "IgnoreDataMember" or "IgnoreDataMemberAttribute")) + .Where(f => !f.GetAttributes() + .Any(a => a.AttributeClass?.Name is "IgnoreDataMember" or "IgnoreDataMemberAttribute")) .Where(f => !f.Name.Contains("<")) .Select(ProcessField) .ToArray(); var usings = HardCodedNamespaces.Concat(fields.Where(x => x.Namespace != string.Empty) - .Select(x => x.Namespace)) - .Distinct() - .Select(x => $"using {x};") - .ToArray(); + .Select(x => x.Namespace)) + .Distinct() + .Select(x => $"using {x};") + .ToArray(); + + var propertyCopies = classSymbol.GetMembers().OfType() + .Where(x => x.Type.ContainingNamespace.ToString().Contains(".State")) + .Select(x => $"{x.Name}.CopyTo(target.{x.Name});") + .ToArray(); + + var internalCopy = classSymbol.GetMembers().OfType().Any(x => x.Name == "CopyToInternal") + ? "CopyToInternal(target);" + : string.Empty; + + var copyTo = $$""" + internal void CopyTo({{classSymbol.Name}} target) { + {{string.Join("\n", propertyCopies)}} + {{string.Join("\n", fields.Select(x => x.CopyCode))}} + {{internalCopy}} + } + """; + + if (classSymbol.GetMembers().OfType().Any(x => x.Name == "CopyTo")) + { + copyTo = string.Empty; + } spc.AddSource($"{classSymbol.Name}.g.cs", $$""" #nullable enable @@ -49,6 +72,9 @@ protected virtual void OnPropertyChanged([CallerMemberName] string? propertyName { PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); } + + {{copyTo}} + } """); } @@ -73,28 +99,29 @@ private ProcessedField ProcessField(IFieldSymbol field) var sendUpdateDeclaration = isReadOnly ? string.Empty : $"private partial void Send{propertyName}UpdateCommand({type} value);"; var code = $$""" - {{Helpers.CodeGeneratorAttribute}} - public {{type}} {{propertyName}} { - get => {{field.Name}}; - {{setterCode}} - } + {{Helpers.CodeGeneratorAttribute}} + public {{type}} {{propertyName}} { + get => {{field.Name}}; + {{setterCode}} + } - {{Helpers.CodeGeneratorAttribute}} - internal void Update{{propertyName}}({{type}} value) { - {{field.Name}} = value; - {{propertyName}}Changed?.Invoke(this, EventArgs.Empty); - OnPropertyChanged(); - } + {{Helpers.CodeGeneratorAttribute}} + internal void Update{{propertyName}}({{type}} value) { + {{field.Name}} = value; + {{propertyName}}Changed?.Invoke(this, EventArgs.Empty); + OnPropertyChanged(); + } - public event EventHandler? {{propertyName}}Changed; + public event EventHandler? {{propertyName}}Changed; - {{sendUpdateDeclaration}} - """; + {{sendUpdateDeclaration}} + """; return new ProcessedField { FieldCode = code, Namespace = field.Type.ContainingNamespace.ToString() ?? string.Empty, + CopyCode = $"target.Update{propertyName}({propertyName});" }; } @@ -108,5 +135,6 @@ public class ProcessedField { public string FieldCode { get; set; } = string.Empty; public string Namespace { get; set; } = string.Empty; + public string CopyCode { get; set; } = string.Empty; } } diff --git a/AtemSharp.Tests/AtemSharp.Tests.csproj b/AtemSharp.Tests/AtemSharp.Tests.csproj index f79b04aa..314657fb 100644 --- a/AtemSharp.Tests/AtemSharp.Tests.csproj +++ b/AtemSharp.Tests/AtemSharp.Tests.csproj @@ -15,9 +15,10 @@ - + + diff --git a/AtemSharp.Tests/Batch/BatchOperationTests.cs b/AtemSharp.Tests/Batch/BatchOperationTests.cs new file mode 100644 index 00000000..d238cf31 --- /dev/null +++ b/AtemSharp.Tests/Batch/BatchOperationTests.cs @@ -0,0 +1,167 @@ +using AtemSharp.Batch; +using AtemSharp.Tests.TestUtilities; + +namespace AtemSharp.Tests.Batch; + +[TestFixture] +public class BatchOperationTests +{ + [Test] + public async Task QueueDifferentCommands() + { + var switcher = new AtemSwitcherFake(); + var sut = new BatchOperation(switcher); + + await sut.SendCommandsAsync([ + new NonMergeableCommand(2), + new NonMergeableCommand(4), + new NonMergeableCommand(6), + new NonMergeableCommand(8), + ]); + + Assert.That(switcher.SendRequests, Has.Count.EqualTo(0)); + + await sut.CommitAsync(); + + Assert.That(switcher.SendRequests, Has.Count.EqualTo(1)); + Assert.That(switcher.SentCommands, Has.Length.EqualTo(4)); + + Assert.That(switcher.SentCommands.Select(x => x.As().Value), Is.EquivalentTo(new[] { 2, 4, 6, 8 })); + } + + [Test] + public async Task MergeCommandsIfPossible() + { + var switcher = new AtemSwitcherFake(); + var sut = new BatchOperation(switcher); + + await sut.SendCommandAsync(new MergeableCommand(2)); + await sut.SendCommandAsync(new MergeableCommand(4)); + await sut.SendCommandAsync(new MergeableCommand(6)); + await sut.SendCommandAsync(new MergeableCommand(8)); + + Assert.That(switcher.SendRequests, Has.Count.EqualTo(0)); + + await sut.CommitAsync(); + + Assert.That(switcher.SendRequests, Has.Count.EqualTo(1)); + Assert.That(switcher.SentCommands, Has.Length.EqualTo(1)); + + var sentCommand = switcher.SentCommands[0].As(); + Assert.That(sentCommand.Values, Is.EquivalentTo(new[] { 2, 4, 6, 8 })); + } + + [Test] + public async Task Dispose_CommitsQueuedCommands() + { + var switcher = new AtemSwitcherFake(); + var sut = new BatchOperation(switcher); + + await sut.SendCommandAsync(new MergeableCommand(2)); + + await sut.DisposeAsync(); + + Assert.That(switcher.SendRequests, Has.Count.EqualTo(1)); + Assert.That(switcher.SentCommands, Has.Length.EqualTo(1)); + var sentCommand = switcher.SentCommands[0].As(); + Assert.That(sentCommand.Values, Is.EquivalentTo(new[] { 2 })); + } + + [Test] + public async Task Creation_LoadsFromSwitcher() + { + var switcher = new AtemSwitcherFake(); + switcher.Macros.Populate(5); + for (ushort i = 0; i < 5; i++) + { + switcher.Macros[i].UpdateName($"Name {i}"); + switcher.Macros[i].UpdateDescription($"Description {i}"); + switcher.Macros[i].UpdateIsUsed(true); + switcher.Macros[i].UpdateHasUnsupportedOps(i % 2 == 0); + } + switcher.Macros.Player.UpdateCurrentlyPlaying(switcher.Macros[0]); + switcher.Macros.Player.UpdatePlayLooped(true); + switcher.Macros.Player.UpdatePlaybackIsWaitingForUserAction(true); + + switcher.Macros.Recorder.UpdateCurrentlyRecording(switcher.Macros[1]); + + + var sut = new BatchOperation(switcher); + + await Verify(sut); + + Assert.That(switcher.Macros.Player.CurrentlyPlaying!.Id, Is.EqualTo(sut.Macros.Player.CurrentlyPlaying!.Id)); + Assert.That(switcher.Macros.Player.CurrentlyPlaying.Name, Is.EqualTo(sut.Macros.Player.CurrentlyPlaying.Name)); + Assert.That(switcher.Macros.Player.CurrentlyPlaying.Description, Is.EqualTo(sut.Macros.Player.CurrentlyPlaying.Description)); + Assert.That(switcher.Macros.Player.CurrentlyPlaying.IsUsed, Is.EqualTo(sut.Macros.Player.CurrentlyPlaying.IsUsed)); + Assert.That(switcher.Macros.Player.CurrentlyPlaying.HasUnsupportedOps, Is.EqualTo(sut.Macros.Player.CurrentlyPlaying.HasUnsupportedOps)); + Assert.That(switcher.Macros.Player.PlaybackIsWaitingForUserAction, Is.EqualTo(sut.Macros.Player.PlaybackIsWaitingForUserAction)); + Assert.That(switcher.Macros.Player.PlayLooped, Is.EqualTo(sut.Macros.Player.PlayLooped)); + + Assert.That(switcher.Macros.Recorder.CurrentlyRecording!.Id, Is.EqualTo(sut.Macros.Recorder.CurrentlyRecording!.Id)); + Assert.That(switcher.Macros.Recorder.CurrentlyRecording.Name, Is.EqualTo(sut.Macros.Recorder.CurrentlyRecording.Name)); + Assert.That(switcher.Macros.Recorder.CurrentlyRecording.Description, Is.EqualTo(sut.Macros.Recorder.CurrentlyRecording.Description)); + Assert.That(switcher.Macros.Recorder.CurrentlyRecording.IsUsed, Is.EqualTo(sut.Macros.Recorder.CurrentlyRecording.IsUsed)); + Assert.That(switcher.Macros.Recorder.CurrentlyRecording.HasUnsupportedOps, Is.EqualTo(sut.Macros.Recorder.CurrentlyRecording.HasUnsupportedOps)); + } + + [Test] + public void Revert_ReloadsFromSwitcher() + { + var switcher = new AtemSwitcherFake(); + switcher.Macros.Populate(5); + for (ushort i = 0; i < 5; i++) + { + switcher.Macros[i].UpdateName($"Name {i}"); + switcher.Macros[i].UpdateDescription($"Description {i}"); + } + + var sut = new BatchOperation(switcher); + + switcher.Macros[1].UpdateName("New Name"); + + Assert.That(sut.Macros[1].Name, Is.EqualTo("Name 1")); + + sut.Revert(); + Assert.That(sut.Macros[1].Name, Is.EqualTo("New Name")); + } + + [Test] + public async Task Revert_ClearsQueue() + { + var switcher = new AtemSwitcherFake(); + var sut = new BatchOperation(switcher); + + await sut.SendCommandAsync(new MergeableCommand(2)); + + sut.Revert(); + await sut.CommitAsync(); + + Assert.That(switcher.SendRequests, Has.Count.EqualTo(0)); + } + + [Test] + public async Task Commit_ClearsQueue() + { + var switcher = new AtemSwitcherFake(); + var sut = new BatchOperation(switcher); + + await sut.SendCommandAsync(new MergeableCommand(2)); + await sut.CommitAsync(); + await sut.CommitAsync(); + + Assert.That(switcher.SendRequests, Has.Count.EqualTo(1)); + Assert.That(switcher.SentCommands, Has.Length.EqualTo(1)); + Assert.That(switcher.SentCommands[0].As().Values, Is.EquivalentTo(new[] { 2 })); + } + + [Test] + public async Task EmptyCommit_DoesNotSend() + { + var switcher = new AtemSwitcherFake(); + var sut = new BatchOperation(switcher); + + await sut.CommitAsync(); + Assert.That(switcher.SendRequests, Has.Count.EqualTo(0)); + } +} diff --git a/AtemSharp.Tests/Batch/MergeableCommand.cs b/AtemSharp.Tests/Batch/MergeableCommand.cs new file mode 100644 index 00000000..6b2e33f2 --- /dev/null +++ b/AtemSharp.Tests/Batch/MergeableCommand.cs @@ -0,0 +1,21 @@ +using AtemSharp.Commands; +using AtemSharp.State.Info; +using AtemSharp.Tests.TestUtilities; + +namespace AtemSharp.Tests.Batch; + +internal class MergeableCommand(int value) : SerializedCommand +{ + public List Values { get; } = [value]; + + public override byte[] Serialize(ProtocolVersion version) + { + return []; + } + + internal override bool TryMergeTo(SerializedCommand other) + { + other.As().Values.Add(value); + return true; + } +} diff --git a/AtemSharp.Tests/Batch/NonMergeableCommand.cs b/AtemSharp.Tests/Batch/NonMergeableCommand.cs new file mode 100644 index 00000000..55f55641 --- /dev/null +++ b/AtemSharp.Tests/Batch/NonMergeableCommand.cs @@ -0,0 +1,19 @@ +using AtemSharp.Commands; +using AtemSharp.State.Info; + +namespace AtemSharp.Tests.Batch; + +internal class NonMergeableCommand(int value) : SerializedCommand +{ + public int Value { get; } = value; + + public override byte[] Serialize(ProtocolVersion version) + { + return []; + } + + internal override bool TryMergeTo(SerializedCommand other) + { + return false; + } +} diff --git a/AtemSharp.Tests/Commands/Macro/MacroActionCommandTests.cs b/AtemSharp.Tests/Commands/Macro/MacroActionCommandTests.cs index dde7c3ec..bdb7ca44 100644 --- a/AtemSharp.Tests/Commands/Macro/MacroActionCommandTests.cs +++ b/AtemSharp.Tests/Commands/Macro/MacroActionCommandTests.cs @@ -1,4 +1,6 @@ using AtemSharp.Commands.Macro; +using AtemSharp.State.Macro; +using AtemSharp.Tests.Batch; using NSubstitute; namespace AtemSharp.Tests.Commands.Macro; @@ -26,4 +28,147 @@ protected override MacroActionCommand CreateSut(TestUtilities.CommandTests.TestC _ => throw new ArgumentOutOfRangeException() }; } + + [Test] + public void LatestRunWins() + { + var state = new MacroSystem(Substitute.For()); + state.Populate(5); + + var first = MacroActionCommand.Run(state[0]); + var second = MacroActionCommand.Run(state[1]); + + Assert.That(second.TryMergeTo(first), Is.True); + Assert.Multiple(() => + { + Assert.That(first.Action, Is.EqualTo(MacroAction.Run)); + Assert.That(first.Index, Is.EqualTo(1)); + }); + } + + [Test] + public void MergeStop() + { + var state = new MacroSystem(Substitute.For()); + state.Populate(5); + + var first = MacroActionCommand.Stop(); + var second = MacroActionCommand.Stop(); + + Assert.That(second.TryMergeTo(first), Is.True); + Assert.Multiple(() => + { + Assert.That(first.Action, Is.EqualTo(MacroAction.Stop)); + Assert.That(first.Index, Is.EqualTo(0xFFFF)); + }); + } + + [Test] + public void MergeStopRecord() + { + var state = new MacroSystem(Substitute.For()); + state.Populate(5); + + var first = MacroActionCommand.StopRecord(); + var second = MacroActionCommand.StopRecord(); + + Assert.That(second.TryMergeTo(first), Is.True); + Assert.Multiple(() => + { + Assert.That(first.Action, Is.EqualTo(MacroAction.StopRecord)); + Assert.That(first.Index, Is.EqualTo(0xFFFF)); + }); + } + + [Test] + public void MergeInsertUserWait() + { + var state = new MacroSystem(Substitute.For()); + state.Populate(5); + + var first = MacroActionCommand.InsertUserWait(); + var second = MacroActionCommand.InsertUserWait(); + + Assert.That(second.TryMergeTo(first), Is.True); + Assert.Multiple(() => + { + Assert.That(first.Action, Is.EqualTo(MacroAction.InsertUserWait)); + Assert.That(first.Index, Is.EqualTo(0xFFFF)); + }); + } + + [Test] + public void MergeContinue() + { + var state = new MacroSystem(Substitute.For()); + state.Populate(5); + + var first = MacroActionCommand.Continue(); + var second = MacroActionCommand.Continue(); + + Assert.That(second.TryMergeTo(first), Is.True); + Assert.Multiple(() => + { + Assert.That(first.Action, Is.EqualTo(MacroAction.Continue)); + Assert.That(first.Index, Is.EqualTo(0xFFFF)); + }); + } + + [Test] + public void MergeDeleteIfIndexIsEqual() + { + var state = new MacroSystem(Substitute.For()); + state.Populate(5); + + var first = MacroActionCommand.Delete(state[2]); + var second = MacroActionCommand.Delete(state[2]); + + Assert.That(second.TryMergeTo(first), Is.True); + Assert.Multiple(() => + { + Assert.That(first.Action, Is.EqualTo(MacroAction.Delete)); + Assert.That(first.Index, Is.EqualTo(2)); + }); + } + + [Test] + public void DontMergeDeleteIfIndexIsDifferent() + { + var state = new MacroSystem(Substitute.For()); + state.Populate(5); + + var first = MacroActionCommand.Delete(state[2]); + var second = MacroActionCommand.Delete(state[3]); + + Assert.That(second.TryMergeTo(first), Is.False); + Assert.Multiple(() => + { + Assert.That(first.Action, Is.EqualTo(MacroAction.Delete)); + Assert.That(first.Index, Is.EqualTo(2)); + Assert.That(second.Action, Is.EqualTo(MacroAction.Delete)); + Assert.That(second.Index, Is.EqualTo(3)); + }); + } + + [Test] + public void DoesNotMergeWithDifferentCommandType() + { + var state = new MacroSystem(Substitute.For()); + state.Populate(5); + + var sut = MacroActionCommand.InsertUserWait(); + + Assert.That(sut.TryMergeTo(new MergeableCommand(2)), Is.False); + } + + [Test] + public void DoesNotMergeWithDifferentAction() + { + var state = new MacroSystem(Substitute.For()); + state.Populate(5); + + var sut = MacroActionCommand.InsertUserWait(); + + Assert.That(sut.TryMergeTo(MacroActionCommand.Continue()), Is.False); + } } diff --git a/AtemSharp.Tests/Commands/Macro/MacroAddTimedPauseCommandTests.cs b/AtemSharp.Tests/Commands/Macro/MacroAddTimedPauseCommandTests.cs index cb74278b..050679f7 100644 --- a/AtemSharp.Tests/Commands/Macro/MacroAddTimedPauseCommandTests.cs +++ b/AtemSharp.Tests/Commands/Macro/MacroAddTimedPauseCommandTests.cs @@ -1,4 +1,5 @@ using AtemSharp.Commands.Macro; +using AtemSharp.Tests.Batch; namespace AtemSharp.Tests.Commands.Macro; @@ -17,4 +18,22 @@ protected override MacroAddTimedPauseCommand CreateSut(TestUtilities.CommandTest Frames = testCase.Command.Frames }; } + + [Test] + public void MergeCommand() + { + var first = new MacroAddTimedPauseCommand { Frames = 2 }; + var second = new MacroAddTimedPauseCommand { Frames = 3 }; + + Assert.That(second.TryMergeTo(first), Is.True); + Assert.That(first.Frames, Is.EqualTo(5)); + } + + [Test] + public void DoesNotMergeWithDifferentCommandType() + { + var sut = new MacroAddTimedPauseCommand { Frames = 2 }; + + Assert.That(sut.TryMergeTo(new MergeableCommand(2)), Is.False); + } } diff --git a/AtemSharp.Tests/Commands/Macro/MacroPropertiesCommandTests.cs b/AtemSharp.Tests/Commands/Macro/MacroPropertiesCommandTests.cs index e6540ddd..58ab031b 100644 --- a/AtemSharp.Tests/Commands/Macro/MacroPropertiesCommandTests.cs +++ b/AtemSharp.Tests/Commands/Macro/MacroPropertiesCommandTests.cs @@ -1,4 +1,5 @@ using AtemSharp.Commands.Macro; +using AtemSharp.State.Macro; using NSubstitute; namespace AtemSharp.Tests.Commands.Macro; @@ -24,4 +25,87 @@ protected override MacroPropertiesCommand CreateSut(TestUtilities.CommandTests.T return new MacroPropertiesCommand(macro); } + + [Test] + public void DoesNotMergeIfIndexIsDifferent() + { + var state = new MacroSystem(Substitute.For()); + state.Populate(5); + state[2].UpdateDescription("Desc1"); + state[2].UpdateName("Name1"); + state[3].UpdateDescription("Desc2"); + state[3].UpdateName("Name2"); + + var first = new MacroPropertiesCommand(state[2]); + var second = new MacroPropertiesCommand(state[3]); + + Assert.That(second.TryMergeTo(first), Is.False); + Assert.Multiple(() => + { + Assert.That(first.Id, Is.EqualTo(2)); + Assert.That(second.Id, Is.EqualTo(3)); + Assert.That(first.Name, Is.EqualTo("Name1")); + Assert.That(second.Name, Is.EqualTo("Name2")); + Assert.That(first.Description, Is.EqualTo("Desc1")); + Assert.That(second.Description, Is.EqualTo("Desc2")); + }); + } + + [Test] + public void MergeOnlyWhenChanged() + { + var state = new MacroSystem(Substitute.For()); + state.Populate(5); + + var first = new MacroPropertiesCommand(state[2]) + { + Name = "Name1", + Description = "Desc1" + }; + var second = new MacroPropertiesCommand(state[2]) + { + Name = "Name2" + }; + + Assert.That(second.TryMergeTo(first), Is.True); + Assert.Multiple(() => + { + Assert.That(first.Id, Is.EqualTo(2)); + Assert.That(first.Name, Is.EqualTo("Name2")); + Assert.That(first.Description, Is.EqualTo("Desc1")); + }); + } + + public static IEnumerable PropertyMergeTestCases() + { + yield return CreateMergingTestCase(nameof(MacroPropertiesCommand.Name), "Name1", "Name2"); + yield return CreateMergingTestCase(nameof(MacroPropertiesCommand.Description), "Desc1", "Desc2"); + } + + static MacroPropertiesCommand Factory() + { + var state = new MacroSystem(Substitute.For()); + state.Populate(5); + return new MacroPropertiesCommand(state[2]); + } + + [Test] + [TestCaseSource(nameof(PropertyMergeTestCases))] + public void IfPropertyChangedOnNewCommand_ItIsChangedOnOldCommand(string property, object firstValue, object secondValue) + { + TestPropertyMerging(Factory, property, firstValue, secondValue); + } + + [Test] + [TestCaseSource(nameof(PropertyMergeTestCases))] + public void IfPropertyIsUnchangedOnNewCommand_ItRetainsTheValueOfTheOldCommand(string property, object firstValue, object secondValue) + { + TestPropertyNonMerging(Factory, property, firstValue, secondValue); + } + + [Test] + public void TestPropertyMerging_WithWrongType() + { + TestPropertyMerging_WithWrongType(Factory); + } } diff --git a/AtemSharp.Tests/Commands/Macro/MacroRecordCommandTests.cs b/AtemSharp.Tests/Commands/Macro/MacroRecordCommandTests.cs index fc8c8ddd..795839e3 100644 --- a/AtemSharp.Tests/Commands/Macro/MacroRecordCommandTests.cs +++ b/AtemSharp.Tests/Commands/Macro/MacroRecordCommandTests.cs @@ -1,4 +1,5 @@ using AtemSharp.Commands.Macro; +using AtemSharp.State.Macro; using NSubstitute; namespace AtemSharp.Tests.Commands.Macro; @@ -24,4 +25,52 @@ protected override MacroRecordCommand CreateSut(TestUtilities.CommandTests.TestC return new MacroRecordCommand(macro); } + + static MacroRecordCommand Factory() + { + var state = new MacroSystem(Substitute.For()); + state.Populate(5); + return new MacroRecordCommand(state[2]); + } + + [Test] + public void NewMacroId_ReplacedOldCommand() + { + var state = new MacroSystem(Substitute.For()); + state.Populate(5); + + var first = new MacroRecordCommand(state[2]) { Name = "First" , Description = "First Macro" }; + var second = new MacroRecordCommand(state[3]) { Name = "Second" , Description = "Second Macro" }; + + Assert.That(second.TryMergeTo(first), Is.True); + Assert.That(first.Index, Is.EqualTo(3)); + Assert.That(first.Name, Is.EqualTo("Second")); + Assert.That(first.Description, Is.EqualTo("Second Macro")); + } + + public static IEnumerable PropertyMergeTestCases() + { + yield return CreateMergingTestCase(nameof(MacroRecordCommand.Name), "Name1", "Name2"); + yield return CreateMergingTestCase(nameof(MacroRecordCommand.Description), "Desc1", "Desc2"); + } + + [Test] + [TestCaseSource(nameof(PropertyMergeTestCases))] + public void IfPropertyChangedOnNewCommand_ItIsChangedOnOldCommand(string property, object firstValue, object secondValue) + { + TestPropertyMerging(Factory, property, firstValue, secondValue); + } + + [Test] + [TestCaseSource(nameof(PropertyMergeTestCases))] + public void IfPropertyIsUnchangedOnNewCommand_ItRetainsTheValueOfTheOldCommand(string property, object firstValue, object secondValue) + { + TestPropertyNonMerging(Factory, property, firstValue, secondValue); + } + + [Test] + public void TestPropertyMerging_WithWrongType() + { + TestPropertyMerging_WithWrongType(Factory); + } } diff --git a/AtemSharp.Tests/Commands/Macro/MacroRunStatusCommandTests.cs b/AtemSharp.Tests/Commands/Macro/MacroRunStatusCommandTests.cs index 8cd469c3..dbecf0d7 100644 --- a/AtemSharp.Tests/Commands/Macro/MacroRunStatusCommandTests.cs +++ b/AtemSharp.Tests/Commands/Macro/MacroRunStatusCommandTests.cs @@ -15,4 +15,24 @@ protected override MacroRunStatusCommand CreateSut(TestUtilities.CommandTests.Te Loop = testCase.Command.Loop }; } + + [Test] + [TestCase(false, false, false)] + [TestCase(true, false, false)] + [TestCase(true, true, true)] + [TestCase(false, true, true)] + public void NewCommandOverridesOldValue(bool first, bool second, bool expected) + { + var firstCommand = new MacroRunStatusCommand { Loop = first }; + var secondCommand = new MacroRunStatusCommand { Loop = second }; + + Assert.That(secondCommand.TryMergeTo(firstCommand), Is.True); + Assert.That(firstCommand.Loop, Is.EqualTo(expected)); + } + + [Test] + public void TestPropertyMerging_WithWrongType() + { + TestPropertyMerging_WithWrongType(() => new MacroRunStatusCommand()); + } } diff --git a/AtemSharp.Tests/Commands/SerializedCommandTestBase.cs b/AtemSharp.Tests/Commands/SerializedCommandTestBase.cs index eb0fc7b0..78621926 100644 --- a/AtemSharp.Tests/Commands/SerializedCommandTestBase.cs +++ b/AtemSharp.Tests/Commands/SerializedCommandTestBase.cs @@ -1,4 +1,5 @@ using AtemSharp.Commands; +using AtemSharp.Tests.Batch; using AtemSharp.Tests.TestUtilities.CommandTests; using JetBrains.Annotations; using Newtonsoft.Json; @@ -67,6 +68,57 @@ public static IEnumerable GetTestCases() return testCases; } + public static TestCaseData CreateMergingTestCase( + string propertyName, + TValue firstValue, + TValue secondValue) + { + return new TestCaseData(propertyName, firstValue, secondValue).SetName(propertyName); + } + + public void TestPropertyMerging( + Func factory, + string propertyName, + TValue firstValue, + TValue secondValue) + { + var first = factory(); + var second = factory(); + + var getter = typeof(TCommand).GetProperty(propertyName)?.GetMethod ?? throw new InvalidOperationException($"No getter for property {typeof(TCommand)}.{propertyName} found"); + var setter = typeof(TCommand).GetProperty(propertyName)?.SetMethod ?? throw new InvalidOperationException($"No setter for property {typeof(TCommand)}.{propertyName} found"); + + setter.Invoke(first, [firstValue]); + setter.Invoke(second, [secondValue]); + + Assert.That(second.TryMergeTo(first), Is.True); + Assert.That(getter.Invoke(first, []), Is.EqualTo(secondValue)); + } + + public void TestPropertyNonMerging( + Func factory, + string propertyName, + TValue firstValue, + TValue secondValue) + { + var first = factory(); + var second = factory(); + + var getter = typeof(TCommand).GetProperty(propertyName)?.GetMethod ?? throw new InvalidOperationException($"No getter for property {typeof(TCommand)}.{propertyName} found"); + var setter = typeof(TCommand).GetProperty(propertyName)?.SetMethod ?? throw new InvalidOperationException($"No setter for property {typeof(TCommand)}.{propertyName} found"); + + setter.Invoke(first, [firstValue]); + + Assert.That(second.TryMergeTo(first), Is.True); + Assert.That(getter.Invoke(first, []), Is.EqualTo(firstValue)); + } + + public void TestPropertyMerging_WithWrongType(Func factory) + { + var command = factory(); + Assert.That(factory().TryMergeTo(new MergeableCommand(2)), Is.False); + } + [Test, TestCaseSource(nameof(GetTestCases))] public void TestSerialization(TestUtilities.CommandTests.TestCaseData testCase) { diff --git a/AtemSharp.Tests/ModuleInitializer.cs b/AtemSharp.Tests/ModuleInitializer.cs new file mode 100644 index 00000000..62327750 --- /dev/null +++ b/AtemSharp.Tests/ModuleInitializer.cs @@ -0,0 +1,12 @@ +using System.Runtime.CompilerServices; + +namespace AtemSharp.Tests; + +public static class ModuleInitializer +{ + [ModuleInitializer] + public static void Init() + { + UseProjectRelativeDirectory("VerifySnapshots"); + } +} diff --git a/AtemSharp.Tests/State/MacroSystemTests.cs b/AtemSharp.Tests/State/MacroSystemTests.cs index 36312ac1..87c4c23c 100644 --- a/AtemSharp.Tests/State/MacroSystemTests.cs +++ b/AtemSharp.Tests/State/MacroSystemTests.cs @@ -21,7 +21,7 @@ public void ChangeIsLooping(bool newValue) sut.Player.UpdatePlayLooped(!newValue); sut.Player.PlayLooped = newValue; - Assert.That(switcher.SentCommands, Has.Count.EqualTo(1)); + Assert.That(switcher.SentCommands, Has.Length.EqualTo(1)); var command = switcher.SentCommands[0].As(); Assert.That(command.Loop, Is.EqualTo(newValue)); } @@ -38,7 +38,7 @@ public void NoChangeIsLooping(bool newValue) sut.Player.UpdatePlayLooped(newValue); sut.Player.PlayLooped = newValue; - Assert.That(switcher.SentCommands, Has.Count.EqualTo(0)); + Assert.That(switcher.SentCommands, Has.Length.EqualTo(0)); } [Test] @@ -51,9 +51,9 @@ public async Task RunMacro() sut[2].UpdateIsUsed(true); await sut[2].Run().WithTimeout(); - Assert.That(switcher.SentCommands, Has.Count.EqualTo(1)); + Assert.That(switcher.SentCommands, Has.Length.EqualTo(1)); - Assert.That(switcher.SentCommands, Has.Count.EqualTo(1)); + Assert.That(switcher.SentCommands, Has.Length.EqualTo(1)); var command = switcher.SentCommands[0].As(); Assert.Multiple(() => { @@ -72,7 +72,7 @@ public void RunUnusedMacro() sut[2].UpdateIsUsed(false); Assert.ThrowsAsync(() => sut[2].Run().WithTimeout()); - Assert.That(switcher.SentCommands, Has.Count.EqualTo(0)); + Assert.That(switcher.SentCommands, Has.Length.EqualTo(0)); } [Test] @@ -86,7 +86,7 @@ public async Task StopMacro() await sut.Player.StopPlayback().WithTimeout(); - Assert.That(switcher.SentCommands, Has.Count.EqualTo(1)); + Assert.That(switcher.SentCommands, Has.Length.EqualTo(1)); var command = switcher.SentCommands[0].As(); Assert.That(command.Action, Is.EqualTo(MacroAction.Stop)); } @@ -102,7 +102,7 @@ public async Task StopMacroRecord() await sut.Recorder.StopRecording().WithTimeout(); - Assert.That(switcher.SentCommands, Has.Count.EqualTo(1)); + Assert.That(switcher.SentCommands, Has.Length.EqualTo(1)); var command = switcher.SentCommands[0].As(); Assert.That(command.Action, Is.EqualTo(MacroAction.StopRecord)); } @@ -117,7 +117,7 @@ public async Task AddPauseInFrames() await sut.Recorder.AddPause(12).WithTimeout(); - Assert.That(switcher.SentCommands, Has.Count.EqualTo(1)); + Assert.That(switcher.SentCommands, Has.Length.EqualTo(1)); var command = switcher.SentCommands[0].As(); Assert.That(command.Frames, Is.EqualTo(12)); } @@ -131,7 +131,7 @@ public void AddPauseWhileNotRecording() sut.Recorder.UpdateCurrentlyRecording(null); Assert.ThrowsAsync(() => sut.Recorder.AddPause(12).WithTimeout()); - Assert.That(switcher.SentCommands, Has.Count.EqualTo(0)); + Assert.That(switcher.SentCommands, Has.Length.EqualTo(0)); } [Test] @@ -154,7 +154,7 @@ public async Task AddPauseInTimeSpan(double durationInSeconds, ushort expectedFr await sut.Recorder.AddPause(TimeSpan.FromSeconds(durationInSeconds)).WithTimeout(); - Assert.That(switcher.SentCommands, Has.Count.EqualTo(1)); + Assert.That(switcher.SentCommands, Has.Length.EqualTo(1)); var command = switcher.SentCommands[0].As(); Assert.That(command.Frames, Is.EqualTo(expectedFrames)); } @@ -168,7 +168,7 @@ public void ChangeMacroName() sut[2].Name = "New Name"; - Assert.That(switcher.SentCommands, Has.Count.EqualTo(1)); + Assert.That(switcher.SentCommands, Has.Length.EqualTo(1)); var command = switcher.SentCommands[0].As(); Assert.That(command.Name, Is.EqualTo("New Name")); } @@ -182,7 +182,7 @@ public void ChangeMacroDescription() sut[2].Description = "New Description"; - Assert.That(switcher.SentCommands, Has.Count.EqualTo(1)); + Assert.That(switcher.SentCommands, Has.Length.EqualTo(1)); var command = switcher.SentCommands[0].As(); Assert.That(command.Description, Is.EqualTo("New Description")); } @@ -196,7 +196,7 @@ public async Task RecordMacro() await sut[2].Record("My Macro", "My Description").WithTimeout(); - Assert.That(switcher.SentCommands, Has.Count.EqualTo(1)); + Assert.That(switcher.SentCommands, Has.Length.EqualTo(1)); var command = switcher.SentCommands[0].As(); Assert.Multiple(() => { @@ -215,7 +215,7 @@ public async Task Continue() await sut.Player.Continue().WithTimeout(); - Assert.That(switcher.SentCommands, Has.Count.EqualTo(1)); + Assert.That(switcher.SentCommands, Has.Length.EqualTo(1)); var command = switcher.SentCommands[0].As(); Assert.That(command.Action, Is.EqualTo(MacroAction.Continue)); } @@ -231,7 +231,7 @@ public async Task AddWaitForUser() await sut.Recorder.AddWaitForUser().WithTimeout(); - Assert.That(switcher.SentCommands, Has.Count.EqualTo(1)); + Assert.That(switcher.SentCommands, Has.Length.EqualTo(1)); var command = switcher.SentCommands[0].As(); Assert.That(command.Action, Is.EqualTo(MacroAction.InsertUserWait)); } diff --git a/AtemSharp.Tests/TestUtilities/AtemClientFake.cs b/AtemSharp.Tests/TestUtilities/AtemClientFake.cs index 2aad3f5e..c990b112 100644 --- a/AtemSharp.Tests/TestUtilities/AtemClientFake.cs +++ b/AtemSharp.Tests/TestUtilities/AtemClientFake.cs @@ -56,13 +56,13 @@ async Task IAtemClient.DisconnectAsync() _remoteEndPoint = null; } - Task IAtemClient.SendCommandAsync(SerializedCommand command) + Task ICommandSender.SendCommandAsync(SerializedCommand command) { SentCommands.Add(command); return Task.CompletedTask; } - async Task IAtemClient.SendCommandsAsync(IEnumerable commands) + async Task ICommandSender.SendCommandsAsync(IEnumerable commands) { IAtemClient self = this; foreach (var command in commands) diff --git a/AtemSharp.Tests/TestUtilities/AtemSwitcherFake.cs b/AtemSharp.Tests/TestUtilities/AtemSwitcherFake.cs index ead965dc..3594119d 100644 --- a/AtemSharp.Tests/TestUtilities/AtemSwitcherFake.cs +++ b/AtemSharp.Tests/TestUtilities/AtemSwitcherFake.cs @@ -1,3 +1,4 @@ +using AtemSharp.Batch; using AtemSharp.Commands; using AtemSharp.State; using AtemSharp.State.Macro; @@ -6,7 +7,14 @@ namespace AtemSharp.Tests.TestUtilities; public class AtemSwitcherFake : IAtemSwitcher { - public readonly List SentCommands = []; + public readonly List SendRequests = []; + + public AtemSwitcherFake() + { + Macros = new MacroSystem(this); + } + + public SerializedCommand[] SentCommands => SendRequests.SelectMany(x => x).ToArray(); public ValueTask DisposeAsync() { @@ -15,7 +23,7 @@ public ValueTask DisposeAsync() public AtemState State { get; internal set; } = null!; - public MacroSystem Macros { get; internal set; } = null!; + public MacroSystem Macros { get; internal set; } public ConnectionState ConnectionState { get; internal set; } = ConnectionState.Connected; @@ -29,6 +37,11 @@ public Task DisconnectAsync() return Task.CompletedTask; } + public IBatchOperation StartBatch() + { + return new BatchOperation(this); + } + public Task SendCommandAsync(SerializedCommand command) { SendCommandsAsync([command]); @@ -37,7 +50,7 @@ public Task SendCommandAsync(SerializedCommand command) public Task SendCommandsAsync(IEnumerable commands) { - SentCommands.AddRange(commands); + SendRequests.Add(commands.ToArray()); return Task.CompletedTask; } } diff --git a/AtemSharp.Tests/TestUtilities/TestCommands/NoRawNameCommand.cs b/AtemSharp.Tests/TestUtilities/TestCommands/NoRawNameCommand.cs index 2eb330fa..79fe24b3 100644 --- a/AtemSharp.Tests/TestUtilities/TestCommands/NoRawNameCommand.cs +++ b/AtemSharp.Tests/TestUtilities/TestCommands/NoRawNameCommand.cs @@ -12,4 +12,9 @@ public override byte[] Serialize(ProtocolVersion version) SerializeCalled = true; return []; } + + internal override bool TryMergeTo(SerializedCommand other) + { + return false; + } } diff --git a/AtemSharp.Tests/TestUtilities/TestCommands/VariableSizeCommand.cs b/AtemSharp.Tests/TestUtilities/TestCommands/VariableSizeCommand.cs index c046cc09..fb2a52e8 100644 --- a/AtemSharp.Tests/TestUtilities/TestCommands/VariableSizeCommand.cs +++ b/AtemSharp.Tests/TestUtilities/TestCommands/VariableSizeCommand.cs @@ -11,4 +11,9 @@ public override byte[] Serialize(ProtocolVersion version) { return Enumerable.Repeat(value, size).ToArray(); } + + internal override bool TryMergeTo(SerializedCommand other) + { + return false; + } } diff --git a/AtemSharp.Tests/VerifySnapshots/BatchOperationTests.Creation_LoadsFromSwitcher.verified.txt b/AtemSharp.Tests/VerifySnapshots/BatchOperationTests.Creation_LoadsFromSwitcher.verified.txt new file mode 100644 index 00000000..a937b10c --- /dev/null +++ b/AtemSharp.Tests/VerifySnapshots/BatchOperationTests.Creation_LoadsFromSwitcher.verified.txt @@ -0,0 +1,38 @@ +{ + Macros: [ + { + Name: Name 0, + Description: Description 0, + IsUsed: true, + HasUnsupportedOps: true + }, + { + Id: 1, + Name: Name 1, + Description: Description 1, + IsUsed: true, + HasUnsupportedOps: false + }, + { + Id: 2, + Name: Name 2, + Description: Description 2, + IsUsed: true, + HasUnsupportedOps: true + }, + { + Id: 3, + Name: Name 3, + Description: Description 3, + IsUsed: true, + HasUnsupportedOps: false + }, + { + Id: 4, + Name: Name 4, + Description: Description 4, + IsUsed: true, + HasUnsupportedOps: true + } + ] +} diff --git a/AtemSharp/AtemSwitcher.cs b/AtemSharp/AtemSwitcher.cs index d305261d..e88791f7 100644 --- a/AtemSharp/AtemSwitcher.cs +++ b/AtemSharp/AtemSwitcher.cs @@ -1,4 +1,5 @@ using System.Threading.Tasks.Dataflow; +using AtemSharp.Batch; using AtemSharp.Commands; using AtemSharp.Communication; using AtemSharp.DependencyInjection; @@ -131,6 +132,11 @@ public async Task DisconnectAsync() } } + public IBatchOperation StartBatch() + { + return new BatchOperation(this); + } + /// public async Task SendCommandAsync(SerializedCommand command) { diff --git a/AtemSharp/Batch/BatchOperation.cs b/AtemSharp/Batch/BatchOperation.cs new file mode 100644 index 00000000..5ff0a599 --- /dev/null +++ b/AtemSharp/Batch/BatchOperation.cs @@ -0,0 +1,80 @@ +using System.Diagnostics.CodeAnalysis; +using AtemSharp.Commands; +using AtemSharp.State; +using AtemSharp.State.Macro; + +namespace AtemSharp.Batch; + +/// +/// Collects commands and sends them in one operation +/// +internal class BatchOperation : IBatchOperation +{ + private readonly IBatchLike _source; + private readonly List _commandsToSend = new(); + + public BatchOperation(IBatchLike source) + { + _source = source; + + State = _source.State; + Macros = new MacroSystem(this); + + Revert(); + } + + /// + [ExcludeFromCodeCoverage(Justification = "Auto-Properties aren't tested")] + public AtemState State { get; } + + /// + [ExcludeFromCodeCoverage(Justification = "Auto-Properties aren't tested")] + public MacroSystem Macros { get; } + + /// + public Task SendCommandAsync(SerializedCommand command) + { + foreach (var cmd in _commandsToSend) + { + if (command.TryMergeTo(cmd)) + { + return Task.CompletedTask; + } + } + + _commandsToSend.Add(command); + + return Task.CompletedTask; + } + + /// + public async Task SendCommandsAsync(IEnumerable commands) + { + await Task.WhenAll(commands.Select(SendCommandAsync)); + } + + /// + public async Task CommitAsync() + { + if (_commandsToSend.Count == 0) + { + return; + } + + await _source.SendCommandsAsync(_commandsToSend); + _commandsToSend.Clear(); + } + + /// + public void Revert() + { + _commandsToSend.Clear(); + _source.Macros.CopyTo(Macros); + } + + /// + public async ValueTask DisposeAsync() + { + await CommitAsync(); + } +} diff --git a/AtemSharp/Batch/IBatchLike.cs b/AtemSharp/Batch/IBatchLike.cs new file mode 100644 index 00000000..69712bd0 --- /dev/null +++ b/AtemSharp/Batch/IBatchLike.cs @@ -0,0 +1,5 @@ +using AtemSharp.Communication; + +namespace AtemSharp.Batch; + +public interface IBatchLike : IStateHolder, ICommandSender; diff --git a/AtemSharp/Batch/IBatchOperation.cs b/AtemSharp/Batch/IBatchOperation.cs new file mode 100644 index 00000000..29bf82a1 --- /dev/null +++ b/AtemSharp/Batch/IBatchOperation.cs @@ -0,0 +1,14 @@ +namespace AtemSharp.Batch; + +public interface IBatchOperation : IBatchLike, IAsyncDisposable +{ + /// + /// Sends all commands that are queued in one operation + /// + Task CommitAsync(); + + /// + /// Removes all queued commands and updates the state from the real device + /// + void Revert(); +} diff --git a/AtemSharp/Commands/Audio/Fairlight/FairlightMixerResetPeakLevelsCommand.cs b/AtemSharp/Commands/Audio/Fairlight/FairlightMixerResetPeakLevelsCommand.cs index f3d857bc..25c1368f 100644 --- a/AtemSharp/Commands/Audio/Fairlight/FairlightMixerResetPeakLevelsCommand.cs +++ b/AtemSharp/Commands/Audio/Fairlight/FairlightMixerResetPeakLevelsCommand.cs @@ -37,4 +37,17 @@ public override byte[] Serialize(ProtocolVersion version) return buffer; } + + internal override bool TryMergeTo(SerializedCommand other) + { + if (other is not FairlightMixerResetPeakLevelsCommand target) + { + return false; + } + + target.All |= All; + target.Master |= Master; + + return true; + } } diff --git a/AtemSharp/Commands/Audio/Fairlight/Master/FairlightMixerMasterDynamicsResetCommand.cs b/AtemSharp/Commands/Audio/Fairlight/Master/FairlightMixerMasterDynamicsResetCommand.cs index 60174ed3..ae69cb0d 100644 --- a/AtemSharp/Commands/Audio/Fairlight/Master/FairlightMixerMasterDynamicsResetCommand.cs +++ b/AtemSharp/Commands/Audio/Fairlight/Master/FairlightMixerMasterDynamicsResetCommand.cs @@ -41,4 +41,19 @@ public override byte[] Serialize(ProtocolVersion version) buffer.WriteUInt8(val, 1); return buffer; } + + internal override bool TryMergeTo(SerializedCommand other) + { + if (other is not FairlightMixerMasterDynamicsResetCommand target) + { + return false; + } + + target.ResetDynamics |= ResetDynamics; + target.ResetExpander |= ResetExpander; + target.ResetCompressor |= ResetCompressor; + target.ResetLimiter |= ResetLimiter; + + return true; + } } diff --git a/AtemSharp/Commands/DataTransfer/DataTransferDataSendCommand.cs b/AtemSharp/Commands/DataTransfer/DataTransferDataSendCommand.cs index d5b46d58..4e4b7d0d 100644 --- a/AtemSharp/Commands/DataTransfer/DataTransferDataSendCommand.cs +++ b/AtemSharp/Commands/DataTransfer/DataTransferDataSendCommand.cs @@ -25,4 +25,9 @@ public override byte[] Serialize(ProtocolVersion version) return buffer; } + + internal override bool TryMergeTo(SerializedCommand other) + { + return false; + } } diff --git a/AtemSharp/Commands/DisplayClock/DisplayClockRequestTimeCommand.cs b/AtemSharp/Commands/DisplayClock/DisplayClockRequestTimeCommand.cs index 7f2df55f..da9a8be8 100644 --- a/AtemSharp/Commands/DisplayClock/DisplayClockRequestTimeCommand.cs +++ b/AtemSharp/Commands/DisplayClock/DisplayClockRequestTimeCommand.cs @@ -10,4 +10,9 @@ public class DisplayClockRequestTimeCommand : SerializedCommand { /// public override byte[] Serialize(ProtocolVersion version) => new byte[4]; + + internal override bool TryMergeTo(SerializedCommand other) + { + return other is DisplayClockRequestTimeCommand; + } } diff --git a/AtemSharp/Commands/Macro/MacroActionCommand.cs b/AtemSharp/Commands/Macro/MacroActionCommand.cs index 74767b04..5a2931ee 100644 --- a/AtemSharp/Commands/Macro/MacroActionCommand.cs +++ b/AtemSharp/Commands/Macro/MacroActionCommand.cs @@ -1,3 +1,5 @@ +using System.ComponentModel; + namespace AtemSharp.Commands.Macro; @@ -8,7 +10,7 @@ namespace AtemSharp.Commands.Macro; [BufferSize(4)] public partial class MacroActionCommand : SerializedCommand { - [SerializedField(0)] [InternalProperty] private readonly ushort _index; + [SerializedField(0)] [InternalProperty] [ReadOnly(true)] private ushort _index; [SerializedField(2)] [InternalProperty] private readonly MacroAction _action; @@ -24,4 +26,33 @@ private MacroActionCommand(ushort index, MacroAction action) public static MacroActionCommand InsertUserWait() => new(0xffff, MacroAction.InsertUserWait); public static MacroActionCommand Continue() => new(0xffff, MacroAction.Continue); public static MacroActionCommand Delete(State.Macro.Macro macro) => new(macro.Id, MacroAction.Delete); + + internal override bool TryMergeTo(SerializedCommand other) + { + if (other is not MacroActionCommand target) + { + return false; + } + + if (target.Action != Action) + { + return false; + } + + switch (Action) + { + case MacroAction.Run: + target._index = Index; + return true; + case MacroAction.Stop: + case MacroAction.StopRecord: + case MacroAction.InsertUserWait: + case MacroAction.Continue: + return true; + case MacroAction.Delete: + return target._index == _index; + default: + throw new ArgumentOutOfRangeException(); + } + } } diff --git a/AtemSharp/Commands/Macro/MacroAddTimedPauseCommand.cs b/AtemSharp/Commands/Macro/MacroAddTimedPauseCommand.cs index fc4ae767..b59a4d59 100644 --- a/AtemSharp/Commands/Macro/MacroAddTimedPauseCommand.cs +++ b/AtemSharp/Commands/Macro/MacroAddTimedPauseCommand.cs @@ -8,4 +8,15 @@ namespace AtemSharp.Commands.Macro; public partial class MacroAddTimedPauseCommand : SerializedCommand { [SerializedField(2)] private ushort _frames; + + internal override bool TryMergeTo(SerializedCommand other) + { + if (other is not MacroAddTimedPauseCommand target) + { + return false; + } + + target.Frames += Frames; + return true; + } } diff --git a/AtemSharp/Commands/Macro/MacroPropertiesCommand.cs b/AtemSharp/Commands/Macro/MacroPropertiesCommand.cs index dac4336c..86218e01 100644 --- a/AtemSharp/Commands/Macro/MacroPropertiesCommand.cs +++ b/AtemSharp/Commands/Macro/MacroPropertiesCommand.cs @@ -13,6 +13,10 @@ public class MacroPropertiesCommand(State.Macro.Macro macro) : SerializedCommand private readonly ushort _id = macro.Id; private string _name = macro.Name; private string _description = macro.Description; + private bool _nameIsDirty; + private bool _descriptionIsDirty; + + internal ushort Id => _id; public string Name { @@ -21,6 +25,7 @@ public string Name { _name = value; Flag |= 1 << 0; + _nameIsDirty = true; } } @@ -31,6 +36,7 @@ public string Description { _description = value; Flag |= 1 << 1; + _descriptionIsDirty = true; } } @@ -47,4 +53,29 @@ public override byte[] Serialize(ProtocolVersion version) return buffer; } + + internal override bool TryMergeTo(SerializedCommand other) + { + if (other is not MacroPropertiesCommand target) + { + return false; + } + + if (target._id != _id) + { + return false; + } + + if (_nameIsDirty) + { + target.Name = Name; + } + + if (_descriptionIsDirty) + { + target.Description = Description; + } + + return true; + } } diff --git a/AtemSharp/Commands/Macro/MacroRecordCommand.cs b/AtemSharp/Commands/Macro/MacroRecordCommand.cs index 9485d513..f73499e4 100644 --- a/AtemSharp/Commands/Macro/MacroRecordCommand.cs +++ b/AtemSharp/Commands/Macro/MacroRecordCommand.cs @@ -9,10 +9,32 @@ namespace AtemSharp.Commands.Macro; [Command("MSRc")] public class MacroRecordCommand(AtemSharp.State.Macro.Macro targetSlot) : SerializedCommand { - internal readonly ushort Index = targetSlot.Id; + internal ushort Index = targetSlot.Id; - public string Name { get; set; } = targetSlot.Name; - public string Description { get; set; } = targetSlot.Description; + public string Name + { + get => _name; + set + { + _name = value; + _nameIsDirty = true; + } + } + + public string Description + { + get => _description; + set + { + _description = value; + _descriptionIsDirty = true; + } + } + + private bool _nameIsDirty; + private bool _descriptionIsDirty; + private string _name = targetSlot.Name; + private string _description = targetSlot.Description; /// public override byte[] Serialize(ProtocolVersion version) @@ -26,4 +48,36 @@ public override byte[] Serialize(ProtocolVersion version) return buffer; } + + internal override bool TryMergeTo(SerializedCommand other) + { + if (other is not MacroRecordCommand target) + { + return false; + } + + // We can only record one macro, so if a new record is queued it replaces the old record + if (target.Index != Index) + { + target.Index = Index; + target.Name = Name; + target.Description = Description; + + return true; + } + + // Else only replace properties which have values that are changed + + if (_nameIsDirty) + { + target.Name = Name; + } + + if (_descriptionIsDirty) + { + target.Description = Description; + } + + return true; + } } diff --git a/AtemSharp/Commands/Media/MediaPoolCaptureStillCommand.cs b/AtemSharp/Commands/Media/MediaPoolCaptureStillCommand.cs index 2e1b57b9..dcca1ae3 100644 --- a/AtemSharp/Commands/Media/MediaPoolCaptureStillCommand.cs +++ b/AtemSharp/Commands/Media/MediaPoolCaptureStillCommand.cs @@ -4,4 +4,10 @@ namespace AtemSharp.Commands.Media; /// Used to tell the media player to capture the current program frame as a still /// [Command("Capt")] -public class MediaPoolCaptureStillCommand : EmptyCommand; +public class MediaPoolCaptureStillCommand : EmptyCommand +{ + internal override bool TryMergeTo(SerializedCommand other) + { + return other is MediaPoolCaptureStillCommand; + } +} diff --git a/AtemSharp/Commands/Media/MediaPoolSettingsSetCommand.cs b/AtemSharp/Commands/Media/MediaPoolSettingsSetCommand.cs index 19742b8a..500de9aa 100644 --- a/AtemSharp/Commands/Media/MediaPoolSettingsSetCommand.cs +++ b/AtemSharp/Commands/Media/MediaPoolSettingsSetCommand.cs @@ -7,6 +7,7 @@ namespace AtemSharp.Commands.Media; [Command("CMPS", ProtocolVersion.V8_0)] public class MediaPoolSettingsSetCommand(MediaPoolSettings settings) : SerializedCommand { + private ushort[] _originalFrames = settings.MaxFrames.ToArray(); public ushort[] MaxFrames { get; } = settings.MaxFrames.ToArray(); // Custom serialization because array size must not be changed @@ -20,4 +21,22 @@ public override byte[] Serialize(ProtocolVersion version) return buffer; } + + internal override bool TryMergeTo(SerializedCommand other) + { + if (other is not MediaPoolSettingsSetCommand target) + { + return false; + } + + for (var i = 0; i < MaxFrames.Length; i++) + { + if (_originalFrames[i] != MaxFrames[i]) + { + target.MaxFrames[i] = MaxFrames[i]; + } + } + + return true; + } } diff --git a/AtemSharp/Commands/Recording/RecordingRequestDurationCommand.cs b/AtemSharp/Commands/Recording/RecordingRequestDurationCommand.cs index 17e769e4..e7ae35cd 100644 --- a/AtemSharp/Commands/Recording/RecordingRequestDurationCommand.cs +++ b/AtemSharp/Commands/Recording/RecordingRequestDurationCommand.cs @@ -5,4 +5,10 @@ namespace AtemSharp.Commands.Recording; /// Used to request the duration of the current recording /// [Command("RMDR")] -public class RecordingRequestDurationCommand : EmptyCommand; +public class RecordingRequestDurationCommand : EmptyCommand +{ + internal override bool TryMergeTo(SerializedCommand other) + { + return other is RecordingRequestDurationCommand; + } +} diff --git a/AtemSharp/Commands/Recording/RecordingRequestSwitchDiskCommand.cs b/AtemSharp/Commands/Recording/RecordingRequestSwitchDiskCommand.cs index b1be3036..344f3b27 100644 --- a/AtemSharp/Commands/Recording/RecordingRequestSwitchDiskCommand.cs +++ b/AtemSharp/Commands/Recording/RecordingRequestSwitchDiskCommand.cs @@ -4,4 +4,10 @@ namespace AtemSharp.Commands.Recording; /// Used to request recording to switch between disks /// [Command("RMSp")] -public class RecordingRequestSwitchDiskCommand : EmptyCommand; +public class RecordingRequestSwitchDiskCommand : EmptyCommand +{ + internal override bool TryMergeTo(SerializedCommand other) + { + return other is RecordingRequestSwitchDiskCommand; + } +} diff --git a/AtemSharp/Commands/SerializedCommand.cs b/AtemSharp/Commands/SerializedCommand.cs index cab5e554..7e0a82b7 100644 --- a/AtemSharp/Commands/SerializedCommand.cs +++ b/AtemSharp/Commands/SerializedCommand.cs @@ -18,4 +18,6 @@ public abstract class SerializedCommand : ICommand /// Protocol version /// Serialized command data public abstract byte[] Serialize(ProtocolVersion version); + + internal abstract bool TryMergeTo(SerializedCommand other); } diff --git a/AtemSharp/Commands/Streaming/StreamingRequestDurationCommand.cs b/AtemSharp/Commands/Streaming/StreamingRequestDurationCommand.cs index f7fdba29..c69ac0f8 100644 --- a/AtemSharp/Commands/Streaming/StreamingRequestDurationCommand.cs +++ b/AtemSharp/Commands/Streaming/StreamingRequestDurationCommand.cs @@ -6,4 +6,10 @@ namespace AtemSharp.Commands.Streaming; /// Used to request the streaming duration update /// [Command("SRDR", ProtocolVersion.V8_1_1)] -public class StreamingRequestDurationCommand : EmptyCommand; +public class StreamingRequestDurationCommand : EmptyCommand +{ + internal override bool TryMergeTo(SerializedCommand other) + { + return other is StreamingRequestDurationCommand; + } +} diff --git a/AtemSharp/Commands/TimeRequestCommand.cs b/AtemSharp/Commands/TimeRequestCommand.cs index acded7ab..d0234db1 100644 --- a/AtemSharp/Commands/TimeRequestCommand.cs +++ b/AtemSharp/Commands/TimeRequestCommand.cs @@ -6,4 +6,10 @@ namespace AtemSharp.Commands; /// Used to request the current time /// [Command("TiRq", ProtocolVersion.V8_0)] -public class TimeRequestCommand : EmptyCommand; +public class TimeRequestCommand : EmptyCommand +{ + internal override bool TryMergeTo(SerializedCommand other) + { + return other is TimeRequestCommand; + } +} diff --git a/AtemSharp/Communication/IAtemClient.cs b/AtemSharp/Communication/IAtemClient.cs index 187e2c6b..57f78f12 100644 --- a/AtemSharp/Communication/IAtemClient.cs +++ b/AtemSharp/Communication/IAtemClient.cs @@ -4,7 +4,7 @@ namespace AtemSharp.Communication; -public interface IAtemClient : IAsyncDisposable +public interface IAtemClient : IAsyncDisposable, ICommandSender { IReceivableSourceBlock ReceivedCommands { get; } @@ -19,14 +19,4 @@ public interface IAtemClient : IAsyncDisposable /// Disconnects from the ATEM device /// Task DisconnectAsync(); - - /// - /// Sends a command to the ATEM device - /// - Task SendCommandAsync(SerializedCommand command); - - /// - /// Sends a series of commands to the ATEM device - /// - Task SendCommandsAsync(IEnumerable commands); } diff --git a/AtemSharp/Communication/ICommandSender.cs b/AtemSharp/Communication/ICommandSender.cs new file mode 100644 index 00000000..faefb5d7 --- /dev/null +++ b/AtemSharp/Communication/ICommandSender.cs @@ -0,0 +1,16 @@ +using AtemSharp.Commands; + +namespace AtemSharp.Communication; + +public interface ICommandSender +{ + /// + /// Sends a command to the ATEM device + /// + Task SendCommandAsync(SerializedCommand command); + + /// + /// Sends a series of commands to the ATEM device + /// + Task SendCommandsAsync(IEnumerable commands); +} diff --git a/AtemSharp/IAtemSwitcher.cs b/AtemSharp/IAtemSwitcher.cs index 5da7182e..4b62b494 100644 --- a/AtemSharp/IAtemSwitcher.cs +++ b/AtemSharp/IAtemSwitcher.cs @@ -1,8 +1,8 @@ -using AtemSharp.Commands; +using AtemSharp.Batch; namespace AtemSharp; -public interface IAtemSwitcher : IAsyncDisposable, IStateHolder +public interface IAtemSwitcher : IAsyncDisposable, IBatchLike { /// /// The state of the connection to the ATEM switcher @@ -22,6 +22,5 @@ public interface IAtemSwitcher : IAsyncDisposable, IStateHolder /// Task that completes when disconnected Task DisconnectAsync(); - Task SendCommandAsync(SerializedCommand command); - Task SendCommandsAsync(IEnumerable commands); + IBatchOperation StartBatch(); } diff --git a/AtemSharp/State/Macro/Macro.cs b/AtemSharp/State/Macro/Macro.cs index aab69078..49672108 100644 --- a/AtemSharp/State/Macro/Macro.cs +++ b/AtemSharp/State/Macro/Macro.cs @@ -1,6 +1,7 @@ using System.ComponentModel; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; +using AtemSharp.Batch; using AtemSharp.Commands.Macro; using AtemSharp.Lib; @@ -11,7 +12,7 @@ namespace AtemSharp.State.Macro; /// [ExcludeFromCodeCoverage(Justification="Auto-Properties aren't tested")] [DebuggerDisplay("{" + nameof(ToString) + ",nq}")] -public partial class Macro(IAtemSwitcher switcher) +public partial class Macro(IBatchLike switcher) { /// /// Gets the id of the macro diff --git a/AtemSharp/State/Macro/MacroPlayer.cs b/AtemSharp/State/Macro/MacroPlayer.cs index 7abc4515..ceae197b 100644 --- a/AtemSharp/State/Macro/MacroPlayer.cs +++ b/AtemSharp/State/Macro/MacroPlayer.cs @@ -1,4 +1,5 @@ using System.ComponentModel; +using AtemSharp.Batch; using AtemSharp.Commands.Macro; using AtemSharp.Lib; @@ -7,7 +8,7 @@ namespace AtemSharp.State.Macro; /// /// The playback engine of the macro system /// -public partial class MacroPlayer(IAtemSwitcher switcher) +public partial class MacroPlayer(IBatchLike switcher) { /// /// Gets/sets whether the macro player should continuously repeat a playing macro until told to stop @@ -44,4 +45,10 @@ public async Task Continue() { await switcher.SendCommandAsync(MacroActionCommand.Continue()); } + + internal void CopyTo(MacroPlayer target) + { + target.UpdatePlayLooped(_playLooped); + target.UpdatePlaybackIsWaitingForUserAction(_playbackIsWaitingForUserAction); + } } diff --git a/AtemSharp/State/Macro/MacroRecorder.cs b/AtemSharp/State/Macro/MacroRecorder.cs index 1a5a13df..d618d06c 100644 --- a/AtemSharp/State/Macro/MacroRecorder.cs +++ b/AtemSharp/State/Macro/MacroRecorder.cs @@ -1,4 +1,5 @@ using System.ComponentModel; +using AtemSharp.Batch; using AtemSharp.Commands.Macro; using AtemSharp.Extensions; @@ -7,7 +8,7 @@ namespace AtemSharp.State.Macro; /// /// The recording part of the macro system /// -public partial class MacroRecorder(IAtemSwitcher switcher) +public partial class MacroRecorder(IBatchLike switcher) { /// /// Gets the macro which is currently being recorded or null if no recording is in progress @@ -58,4 +59,8 @@ public async Task AddWaitForUser() await switcher.SendCommandAsync(MacroActionCommand.InsertUserWait()); } + + internal void CopyTo(MacroRecorder target) + { + } } diff --git a/AtemSharp/State/Macro/MacroSystem.cs b/AtemSharp/State/Macro/MacroSystem.cs index b8a9dab9..7efc705e 100644 --- a/AtemSharp/State/Macro/MacroSystem.cs +++ b/AtemSharp/State/Macro/MacroSystem.cs @@ -1,10 +1,11 @@ +using AtemSharp.Batch; using AtemSharp.Types; namespace AtemSharp.State.Macro; public partial class MacroSystem : ItemCollection { - internal MacroSystem(IAtemSwitcher switcher) : base(id => new Macro(switcher) { Id = id }) + internal MacroSystem(IBatchLike switcher) : base(id => new Macro(switcher) { Id = id }) { Player = new MacroPlayer(switcher); Recorder = new MacroRecorder(switcher); @@ -13,4 +14,15 @@ public partial class MacroSystem : ItemCollection public MacroPlayer Player { get; } public MacroRecorder Recorder { get; } + + private void CopyToInternal(MacroSystem target) + { + foreach (var macro in this) + { + macro.CopyTo(target.GetOrCreate(macro.Id)); + } + + target.Player.UpdateCurrentlyPlaying(Player.CurrentlyPlaying is null ? null : this[Player.CurrentlyPlaying.Id]); + target.Recorder.UpdateCurrentlyRecording(Recorder.CurrentlyRecording is null ? null : this[Recorder.CurrentlyRecording.Id]); + } }