From ed235d1cc8577ee7f04ea5aa6701f03ae587c16f Mon Sep 17 00:00:00 2001 From: Markus Palcer Date: Thu, 8 Jan 2026 20:11:44 +0100 Subject: [PATCH 1/9] generate CopyTo methods in state classes --- .../State/StateGenerator.cs | 66 ++++++++++++------- 1 file changed, 42 insertions(+), 24 deletions(-) diff --git a/AtemSharp.CodeGenerators/State/StateGenerator.cs b/AtemSharp.CodeGenerators/State/StateGenerator.cs index fe7b358..c6477de 100644 --- a/AtemSharp.CodeGenerators/State/StateGenerator.cs +++ b/AtemSharp.CodeGenerators/State/StateGenerator.cs @@ -9,27 +9,37 @@ 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; spc.AddSource($"{classSymbol.Name}.g.cs", $$""" #nullable enable @@ -49,6 +59,12 @@ protected virtual void OnPropertyChanged([CallerMemberName] string? propertyName { PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); } + + internal void CopyTo({{classSymbol.Name}} target) { + {{string.Join("\n", propertyCopies)}} + {{string.Join("\n", fields.Select(x => x.CopyCode))}} + {{internalCopy}} + } } """); } @@ -73,28 +89,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 +125,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; } } From a361170c843e1e0df3b0979deb9c6d2b565f0bbd Mon Sep 17 00:00:00 2001 From: Markus Palcer Date: Sat, 10 Jan 2026 09:24:12 +0100 Subject: [PATCH 2/9] generated merging of commands in a batch --- .../SerializedCommandGenerator.cs | 35 ++++++++++++- .../Serialization/SerializedField.cs | 2 + .../TestCommands/NoRawNameCommand.cs | 5 ++ .../TestCommands/VariableSizeCommand.cs | 5 ++ .../FairlightMixerResetPeakLevelsCommand.cs | 13 +++++ ...airlightMixerMasterDynamicsResetCommand.cs | 15 ++++++ .../DataTransferDataSendCommand.cs | 5 ++ .../DisplayClockRequestTimeCommand.cs | 5 ++ .../Commands/Macro/MacroActionCommand.cs | 20 ++++++++ .../Commands/Macro/MacroPropertiesCommand.cs | 29 +++++++++++ .../Commands/Macro/MacroRecordCommand.cs | 51 ++++++++++++++++++- .../Media/MediaPoolCaptureStillCommand.cs | 8 ++- .../Media/MediaPoolSettingsSetCommand.cs | 19 +++++++ .../RecordingRequestDurationCommand.cs | 8 ++- .../RecordingRequestSwitchDiskCommand.cs | 8 ++- AtemSharp/Commands/SerializedCommand.cs | 2 + .../StreamingRequestDurationCommand.cs | 8 ++- AtemSharp/Commands/TimeRequestCommand.cs | 8 ++- 18 files changed, 237 insertions(+), 9 deletions(-) diff --git a/AtemSharp.CodeGenerators/Serialization/SerializedCommandGenerator.cs b/AtemSharp.CodeGenerators/Serialization/SerializedCommandGenerator.cs index 4191513..105032c 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; } @@ -137,10 +165,13 @@ private static string GetPropertyCode(IFieldSymbol f) {{validationCode}} {{f.Name}} = value; {{flagCode}} + _{{f.Name}}_isDirty = true; } """; return $$""" + private bool _{{f.Name}}_isDirty; + {{docComment}} {{Helpers.CodeGeneratorAttribute}} {{visibility}} {{fieldType}} {{propertyName}} diff --git a/AtemSharp.CodeGenerators/Serialization/SerializedField.cs b/AtemSharp.CodeGenerators/Serialization/SerializedField.cs index 9911f21..aad6bae 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.Tests/TestUtilities/TestCommands/NoRawNameCommand.cs b/AtemSharp.Tests/TestUtilities/TestCommands/NoRawNameCommand.cs index 2eb330f..1e1349b 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) + { + throw new NotImplementedException(); + } } diff --git a/AtemSharp.Tests/TestUtilities/TestCommands/VariableSizeCommand.cs b/AtemSharp.Tests/TestUtilities/TestCommands/VariableSizeCommand.cs index c046cc0..a17cc10 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) + { + throw new NotImplementedException(); + } } diff --git a/AtemSharp/Commands/Audio/Fairlight/FairlightMixerResetPeakLevelsCommand.cs b/AtemSharp/Commands/Audio/Fairlight/FairlightMixerResetPeakLevelsCommand.cs index f3d857b..25c1368 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 60174ed..ae69cb0 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 d5b46d5..4e4b7d0 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 7f2df55..da9a8be 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 74767b0..ba6b2e9 100644 --- a/AtemSharp/Commands/Macro/MacroActionCommand.cs +++ b/AtemSharp/Commands/Macro/MacroActionCommand.cs @@ -24,4 +24,24 @@ 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; + } + + if (target.Index != Index) + { + return false; + } + + return true; + } } diff --git a/AtemSharp/Commands/Macro/MacroPropertiesCommand.cs b/AtemSharp/Commands/Macro/MacroPropertiesCommand.cs index dac4336..7da3de2 100644 --- a/AtemSharp/Commands/Macro/MacroPropertiesCommand.cs +++ b/AtemSharp/Commands/Macro/MacroPropertiesCommand.cs @@ -13,6 +13,8 @@ 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; public string Name { @@ -21,6 +23,7 @@ public string Name { _name = value; Flag |= 1 << 0; + _nameIsDirty = true; } } @@ -31,6 +34,7 @@ public string Description { _description = value; Flag |= 1 << 1; + _descriptionIsDirty = true; } } @@ -47,4 +51,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 9485d51..ed03ea9 100644 --- a/AtemSharp/Commands/Macro/MacroRecordCommand.cs +++ b/AtemSharp/Commands/Macro/MacroRecordCommand.cs @@ -11,8 +11,30 @@ public class MacroRecordCommand(AtemSharp.State.Macro.Macro targetSlot) : Serial { internal readonly 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,29 @@ public override byte[] Serialize(ProtocolVersion version) return buffer; } + + internal override bool TryMergeTo(SerializedCommand other) + { + if (other is not MacroRecordCommand target) + { + return false; + } + + if (target.Index != Index) + { + return false; + } + + 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 2e1b57b..dcca1ae 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 19742b8..500de9a 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 17e769e..e7ae35c 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 b1be303..344f3b2 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 cab5e55..7e0a82b 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 f7fdba2..c69ac0f 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 acded7a..d0234db 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; + } +} From 656a91341b922179d8866aa3c9a3fd0e50b6bac6 Mon Sep 17 00:00:00 2001 From: Markus Palcer Date: Sat, 10 Jan 2026 10:01:33 +0100 Subject: [PATCH 3/9] introduce BatchOperation class --- .../SerializedCommandGenerator.cs | 2 +- .../TestUtilities/AtemClientFake.cs | 4 +- .../TestUtilities/AtemSwitcherFake.cs | 6 ++ AtemSharp/AtemSwitcher.cs | 6 ++ AtemSharp/Batch/BatchOperation.cs | 72 +++++++++++++++++++ AtemSharp/Batch/IBatchLike.cs | 5 ++ AtemSharp/Batch/IBatchOperation.cs | 14 ++++ AtemSharp/Communication/IAtemClient.cs | 12 +--- AtemSharp/Communication/ICommandSender.cs | 16 +++++ AtemSharp/IAtemSwitcher.cs | 7 +- AtemSharp/State/Macro/Macro.cs | 3 +- AtemSharp/State/Macro/MacroPlayer.cs | 3 +- AtemSharp/State/Macro/MacroRecorder.cs | 3 +- AtemSharp/State/Macro/MacroSystem.cs | 3 +- 14 files changed, 134 insertions(+), 22 deletions(-) create mode 100644 AtemSharp/Batch/BatchOperation.cs create mode 100644 AtemSharp/Batch/IBatchLike.cs create mode 100644 AtemSharp/Batch/IBatchOperation.cs create mode 100644 AtemSharp/Communication/ICommandSender.cs diff --git a/AtemSharp.CodeGenerators/Serialization/SerializedCommandGenerator.cs b/AtemSharp.CodeGenerators/Serialization/SerializedCommandGenerator.cs index 105032c..ef31259 100644 --- a/AtemSharp.CodeGenerators/Serialization/SerializedCommandGenerator.cs +++ b/AtemSharp.CodeGenerators/Serialization/SerializedCommandGenerator.cs @@ -170,7 +170,7 @@ private static string GetPropertyCode(IFieldSymbol f) """; return $$""" - private bool _{{f.Name}}_isDirty; + {{ ( f.IsReadOnly ? string.Empty : $"private bool _{f.Name}_isDirty;" ) }} {{docComment}} {{Helpers.CodeGeneratorAttribute}} diff --git a/AtemSharp.Tests/TestUtilities/AtemClientFake.cs b/AtemSharp.Tests/TestUtilities/AtemClientFake.cs index 2aad3f5..c990b11 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 ead965d..096bdff 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; @@ -29,6 +30,11 @@ public Task DisconnectAsync() return Task.CompletedTask; } + public IBatchOperation StartBatch() + { + throw new NotImplementedException(); + } + public Task SendCommandAsync(SerializedCommand command) { SendCommandsAsync([command]); diff --git a/AtemSharp/AtemSwitcher.cs b/AtemSharp/AtemSwitcher.cs index d305261..e88791f 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 0000000..03b2c0c --- /dev/null +++ b/AtemSharp/Batch/BatchOperation.cs @@ -0,0 +1,72 @@ +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(); + } + + /// + public AtemState State { get; } + + /// + 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() + { + 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 0000000..69712bd --- /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 0000000..29bf82a --- /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/Communication/IAtemClient.cs b/AtemSharp/Communication/IAtemClient.cs index 187e2c6..57f78f1 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 0000000..faefb5d --- /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 5da7182..4b62b49 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 aab6907..4967210 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 7abc451..6e4f63c 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 diff --git a/AtemSharp/State/Macro/MacroRecorder.cs b/AtemSharp/State/Macro/MacroRecorder.cs index 1a5a13d..33788c3 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 diff --git a/AtemSharp/State/Macro/MacroSystem.cs b/AtemSharp/State/Macro/MacroSystem.cs index b8a9dab..6928617 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); From 5b06d704277d0d11cb1afbab53e1365e0d321f02 Mon Sep 17 00:00:00 2001 From: Markus Palcer Date: Sat, 10 Jan 2026 10:45:41 +0100 Subject: [PATCH 4/9] tests for batch operation --- AtemSharp.Tests/Batch/BatchOperationTests.cs | 84 +++++++++++++++++++ AtemSharp.Tests/State/MacroSystemTests.cs | 30 +++---- .../TestUtilities/AtemSwitcherFake.cs | 13 ++- 3 files changed, 109 insertions(+), 18 deletions(-) create mode 100644 AtemSharp.Tests/Batch/BatchOperationTests.cs diff --git a/AtemSharp.Tests/Batch/BatchOperationTests.cs b/AtemSharp.Tests/Batch/BatchOperationTests.cs new file mode 100644 index 0000000..ebb0b26 --- /dev/null +++ b/AtemSharp.Tests/Batch/BatchOperationTests.cs @@ -0,0 +1,84 @@ +using AtemSharp.Batch; +using AtemSharp.Commands; +using AtemSharp.State.Info; +using AtemSharp.Tests.TestUtilities; + +namespace AtemSharp.Tests.Batch; + +[TestFixture] +public class BatchOperationTests +{ + private class MergeableCommand(int value) : SerializedCommand + { + public List Values { get; } = [value]; + + public override byte[] Serialize(ProtocolVersion version) + { + throw new NotImplementedException(); + } + + internal override bool TryMergeTo(SerializedCommand other) + { + other.As().Values.Add(value); + return true; + } + } + + private class NonMergeableCommand(int value) : SerializedCommand + { + public int Value { get; } = value; + + public override byte[] Serialize(ProtocolVersion version) + { + throw new NotImplementedException(); + } + + internal override bool TryMergeTo(SerializedCommand other) + { + return false; + } + } + + [Test] + public async Task QueueDifferentCommands() + { + var switcher = new AtemSwitcherFake(); + var sut = new BatchOperation(switcher); + + await sut.SendCommandAsync(new NonMergeableCommand(2)); + await sut.SendCommandAsync(new NonMergeableCommand(4)); + await sut.SendCommandAsync(new NonMergeableCommand(6)); + await sut.SendCommandAsync(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 })); + } +} diff --git a/AtemSharp.Tests/State/MacroSystemTests.cs b/AtemSharp.Tests/State/MacroSystemTests.cs index 36312ac..87c4c23 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/AtemSwitcherFake.cs b/AtemSharp.Tests/TestUtilities/AtemSwitcherFake.cs index 096bdff..9c504c5 100644 --- a/AtemSharp.Tests/TestUtilities/AtemSwitcherFake.cs +++ b/AtemSharp.Tests/TestUtilities/AtemSwitcherFake.cs @@ -7,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() { @@ -16,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; @@ -43,7 +50,7 @@ public Task SendCommandAsync(SerializedCommand command) public Task SendCommandsAsync(IEnumerable commands) { - SentCommands.AddRange(commands); + SendRequests.Add(commands.ToArray()); return Task.CompletedTask; } } From 26cc0f4d67555b8be72dda88b8c3069aad0540af Mon Sep 17 00:00:00 2001 From: Markus Palcer Date: Sat, 10 Jan 2026 15:55:54 +0100 Subject: [PATCH 5/9] add tests for merging in macro commands --- .../SerializedCommandGenerator.cs | 5 +- .../Commands/Macro/MacroActionCommandTests.cs | 122 ++++++++++++++++++ .../Macro/MacroAddTimedPauseCommandTests.cs | 10 ++ .../Macro/MacroPropertiesCommandTests.cs | 78 +++++++++++ .../Commands/Macro/MacroRecordCommandTests.cs | 43 ++++++ .../Macro/MacroRunStatusCommandTests.cs | 14 ++ .../Commands/SerializedCommandTestBase.cs | 45 +++++++ .../Commands/Macro/MacroActionCommand.cs | 21 ++- .../Macro/MacroAddTimedPauseCommand.cs | 11 ++ .../Commands/Macro/MacroPropertiesCommand.cs | 2 + .../Commands/Macro/MacroRecordCommand.cs | 11 +- 11 files changed, 353 insertions(+), 9 deletions(-) diff --git a/AtemSharp.CodeGenerators/Serialization/SerializedCommandGenerator.cs b/AtemSharp.CodeGenerators/Serialization/SerializedCommandGenerator.cs index ef31259..b41cea5 100644 --- a/AtemSharp.CodeGenerators/Serialization/SerializedCommandGenerator.cs +++ b/AtemSharp.CodeGenerators/Serialization/SerializedCommandGenerator.cs @@ -158,7 +158,8 @@ 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 { @@ -170,7 +171,7 @@ private static string GetPropertyCode(IFieldSymbol f) """; return $$""" - {{ ( f.IsReadOnly ? string.Empty : $"private bool _{f.Name}_isDirty;" ) }} + {{ ( hasSetter ? string.Empty : $"private bool _{f.Name}_isDirty;" ) }} {{docComment}} {{Helpers.CodeGeneratorAttribute}} diff --git a/AtemSharp.Tests/Commands/Macro/MacroActionCommandTests.cs b/AtemSharp.Tests/Commands/Macro/MacroActionCommandTests.cs index dde7c3e..395bf47 100644 --- a/AtemSharp.Tests/Commands/Macro/MacroActionCommandTests.cs +++ b/AtemSharp.Tests/Commands/Macro/MacroActionCommandTests.cs @@ -1,4 +1,5 @@ using AtemSharp.Commands.Macro; +using AtemSharp.State.Macro; using NSubstitute; namespace AtemSharp.Tests.Commands.Macro; @@ -26,4 +27,125 @@ 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)); + }); + } } diff --git a/AtemSharp.Tests/Commands/Macro/MacroAddTimedPauseCommandTests.cs b/AtemSharp.Tests/Commands/Macro/MacroAddTimedPauseCommandTests.cs index cb74278..7395585 100644 --- a/AtemSharp.Tests/Commands/Macro/MacroAddTimedPauseCommandTests.cs +++ b/AtemSharp.Tests/Commands/Macro/MacroAddTimedPauseCommandTests.cs @@ -17,4 +17,14 @@ 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)); + } } diff --git a/AtemSharp.Tests/Commands/Macro/MacroPropertiesCommandTests.cs b/AtemSharp.Tests/Commands/Macro/MacroPropertiesCommandTests.cs index e6540dd..80ab82b 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,81 @@ 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); + } } diff --git a/AtemSharp.Tests/Commands/Macro/MacroRecordCommandTests.cs b/AtemSharp.Tests/Commands/Macro/MacroRecordCommandTests.cs index fc8c8dd..3c4e019 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,46 @@ 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); + } } diff --git a/AtemSharp.Tests/Commands/Macro/MacroRunStatusCommandTests.cs b/AtemSharp.Tests/Commands/Macro/MacroRunStatusCommandTests.cs index 8cd469c..dfb68fc 100644 --- a/AtemSharp.Tests/Commands/Macro/MacroRunStatusCommandTests.cs +++ b/AtemSharp.Tests/Commands/Macro/MacroRunStatusCommandTests.cs @@ -15,4 +15,18 @@ 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)); + } } diff --git a/AtemSharp.Tests/Commands/SerializedCommandTestBase.cs b/AtemSharp.Tests/Commands/SerializedCommandTestBase.cs index eb0fc7b..9bf54f9 100644 --- a/AtemSharp.Tests/Commands/SerializedCommandTestBase.cs +++ b/AtemSharp.Tests/Commands/SerializedCommandTestBase.cs @@ -67,6 +67,51 @@ 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)); + } + [Test, TestCaseSource(nameof(GetTestCases))] public void TestSerialization(TestUtilities.CommandTests.TestCaseData testCase) { diff --git a/AtemSharp/Commands/Macro/MacroActionCommand.cs b/AtemSharp/Commands/Macro/MacroActionCommand.cs index ba6b2e9..5a2931e 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; @@ -37,11 +39,20 @@ internal override bool TryMergeTo(SerializedCommand other) return false; } - if (target.Index != Index) + switch (Action) { - return false; + 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(); } - - return true; } } diff --git a/AtemSharp/Commands/Macro/MacroAddTimedPauseCommand.cs b/AtemSharp/Commands/Macro/MacroAddTimedPauseCommand.cs index fc4ae76..b59a4d5 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 7da3de2..86218e0 100644 --- a/AtemSharp/Commands/Macro/MacroPropertiesCommand.cs +++ b/AtemSharp/Commands/Macro/MacroPropertiesCommand.cs @@ -16,6 +16,8 @@ public class MacroPropertiesCommand(State.Macro.Macro macro) : SerializedCommand private bool _nameIsDirty; private bool _descriptionIsDirty; + internal ushort Id => _id; + public string Name { get => _name; diff --git a/AtemSharp/Commands/Macro/MacroRecordCommand.cs b/AtemSharp/Commands/Macro/MacroRecordCommand.cs index ed03ea9..f73499e 100644 --- a/AtemSharp/Commands/Macro/MacroRecordCommand.cs +++ b/AtemSharp/Commands/Macro/MacroRecordCommand.cs @@ -9,7 +9,7 @@ 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 { @@ -56,11 +56,18 @@ internal override bool TryMergeTo(SerializedCommand other) return false; } + // We can only record one macro, so if a new record is queued it replaces the old record if (target.Index != Index) { - return false; + 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; From 87641943e4d4b2f16bf6006d441bbf3f3c0633fa Mon Sep 17 00:00:00 2001 From: Markus Palcer Date: Sat, 10 Jan 2026 16:00:17 +0100 Subject: [PATCH 6/9] implemented not implemented methods with simple implementations --- AtemSharp.Tests/Batch/BatchOperationTests.cs | 4 ++-- AtemSharp.Tests/TestUtilities/AtemSwitcherFake.cs | 2 +- .../TestUtilities/TestCommands/NoRawNameCommand.cs | 2 +- .../TestUtilities/TestCommands/VariableSizeCommand.cs | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/AtemSharp.Tests/Batch/BatchOperationTests.cs b/AtemSharp.Tests/Batch/BatchOperationTests.cs index ebb0b26..1ea58d3 100644 --- a/AtemSharp.Tests/Batch/BatchOperationTests.cs +++ b/AtemSharp.Tests/Batch/BatchOperationTests.cs @@ -14,7 +14,7 @@ private class MergeableCommand(int value) : SerializedCommand public override byte[] Serialize(ProtocolVersion version) { - throw new NotImplementedException(); + return []; } internal override bool TryMergeTo(SerializedCommand other) @@ -30,7 +30,7 @@ private class NonMergeableCommand(int value) : SerializedCommand public override byte[] Serialize(ProtocolVersion version) { - throw new NotImplementedException(); + return []; } internal override bool TryMergeTo(SerializedCommand other) diff --git a/AtemSharp.Tests/TestUtilities/AtemSwitcherFake.cs b/AtemSharp.Tests/TestUtilities/AtemSwitcherFake.cs index 9c504c5..3594119 100644 --- a/AtemSharp.Tests/TestUtilities/AtemSwitcherFake.cs +++ b/AtemSharp.Tests/TestUtilities/AtemSwitcherFake.cs @@ -39,7 +39,7 @@ public Task DisconnectAsync() public IBatchOperation StartBatch() { - throw new NotImplementedException(); + return new BatchOperation(this); } public Task SendCommandAsync(SerializedCommand command) diff --git a/AtemSharp.Tests/TestUtilities/TestCommands/NoRawNameCommand.cs b/AtemSharp.Tests/TestUtilities/TestCommands/NoRawNameCommand.cs index 1e1349b..79fe24b 100644 --- a/AtemSharp.Tests/TestUtilities/TestCommands/NoRawNameCommand.cs +++ b/AtemSharp.Tests/TestUtilities/TestCommands/NoRawNameCommand.cs @@ -15,6 +15,6 @@ public override byte[] Serialize(ProtocolVersion version) internal override bool TryMergeTo(SerializedCommand other) { - throw new NotImplementedException(); + return false; } } diff --git a/AtemSharp.Tests/TestUtilities/TestCommands/VariableSizeCommand.cs b/AtemSharp.Tests/TestUtilities/TestCommands/VariableSizeCommand.cs index a17cc10..fb2a52e 100644 --- a/AtemSharp.Tests/TestUtilities/TestCommands/VariableSizeCommand.cs +++ b/AtemSharp.Tests/TestUtilities/TestCommands/VariableSizeCommand.cs @@ -14,6 +14,6 @@ public override byte[] Serialize(ProtocolVersion version) internal override bool TryMergeTo(SerializedCommand other) { - throw new NotImplementedException(); + return false; } } From dd71c371fa1cf8d2438c442ff0bd1c37aea9671c Mon Sep 17 00:00:00 2001 From: Markus Palcer Date: Sat, 10 Jan 2026 16:10:11 +0100 Subject: [PATCH 7/9] update tools --- .config/dotnet-tools.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index 5406993..4433b16 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" ], From 22f95853a5e269af1ba3389cd14928a3fa154aad Mon Sep 17 00:00:00 2001 From: Markus Palcer Date: Sun, 11 Jan 2026 09:13:14 +0100 Subject: [PATCH 8/9] finalization --- .../SerializedCommandGenerator.cs | 2 +- .../State/StateGenerator.cs | 20 ++- AtemSharp.Tests/AtemSharp.Tests.csproj | 3 +- AtemSharp.Tests/Batch/BatchOperationTests.cs | 124 +++++++++++++++++- .../Commands/SerializedCommandTestBase.cs | 4 +- AtemSharp.Tests/ModuleInitializer.cs | 12 ++ ...ts.Creation_LoadsFromSwitcher.verified.txt | 38 ++++++ AtemSharp/Batch/BatchOperation.cs | 8 ++ AtemSharp/State/Macro/MacroPlayer.cs | 6 + AtemSharp/State/Macro/MacroRecorder.cs | 4 + AtemSharp/State/Macro/MacroSystem.cs | 11 ++ 11 files changed, 219 insertions(+), 13 deletions(-) create mode 100644 AtemSharp.Tests/ModuleInitializer.cs create mode 100644 AtemSharp.Tests/VerifySnapshots/BatchOperationTests.Creation_LoadsFromSwitcher.verified.txt diff --git a/AtemSharp.CodeGenerators/Serialization/SerializedCommandGenerator.cs b/AtemSharp.CodeGenerators/Serialization/SerializedCommandGenerator.cs index b41cea5..0ac8ace 100644 --- a/AtemSharp.CodeGenerators/Serialization/SerializedCommandGenerator.cs +++ b/AtemSharp.CodeGenerators/Serialization/SerializedCommandGenerator.cs @@ -171,7 +171,7 @@ private static string GetPropertyCode(IFieldSymbol f) """; return $$""" - {{ ( hasSetter ? string.Empty : $"private bool _{f.Name}_isDirty;" ) }} + {{ ( hasSetter ? string.Empty : $"#pragma warning disable CS0414\nprivate bool _{f.Name}_isDirty;\n#pragma warning restore CS0414" ) }} {{docComment}} {{Helpers.CodeGeneratorAttribute}} diff --git a/AtemSharp.CodeGenerators/State/StateGenerator.cs b/AtemSharp.CodeGenerators/State/StateGenerator.cs index c6477de..4515f27 100644 --- a/AtemSharp.CodeGenerators/State/StateGenerator.cs +++ b/AtemSharp.CodeGenerators/State/StateGenerator.cs @@ -41,6 +41,19 @@ protected override void ProcessClass(SourceProductionContext spc, INamedTypeSymb ? "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 @@ -60,11 +73,8 @@ protected virtual void OnPropertyChanged([CallerMemberName] string? propertyName PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); } - internal void CopyTo({{classSymbol.Name}} target) { - {{string.Join("\n", propertyCopies)}} - {{string.Join("\n", fields.Select(x => x.CopyCode))}} - {{internalCopy}} - } + {{copyTo}} + } """); } diff --git a/AtemSharp.Tests/AtemSharp.Tests.csproj b/AtemSharp.Tests/AtemSharp.Tests.csproj index f79b04a..314657f 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 index 1ea58d3..aa1fd75 100644 --- a/AtemSharp.Tests/Batch/BatchOperationTests.cs +++ b/AtemSharp.Tests/Batch/BatchOperationTests.cs @@ -45,10 +45,12 @@ public async Task QueueDifferentCommands() var switcher = new AtemSwitcherFake(); var sut = new BatchOperation(switcher); - await sut.SendCommandAsync(new NonMergeableCommand(2)); - await sut.SendCommandAsync(new NonMergeableCommand(4)); - await sut.SendCommandAsync(new NonMergeableCommand(6)); - await sut.SendCommandAsync(new NonMergeableCommand(8)); + await sut.SendCommandsAsync([ + new NonMergeableCommand(2), + new NonMergeableCommand(4), + new NonMergeableCommand(6), + new NonMergeableCommand(8), + ]); Assert.That(switcher.SendRequests, Has.Count.EqualTo(0)); @@ -81,4 +83,118 @@ public async Task MergeCommandsIfPossible() 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/Commands/SerializedCommandTestBase.cs b/AtemSharp.Tests/Commands/SerializedCommandTestBase.cs index 9bf54f9..d021d97 100644 --- a/AtemSharp.Tests/Commands/SerializedCommandTestBase.cs +++ b/AtemSharp.Tests/Commands/SerializedCommandTestBase.cs @@ -85,7 +85,7 @@ public void TestPropertyMerging( 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");; + 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]); @@ -104,7 +104,7 @@ public void TestPropertyNonMerging( 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");; + var setter = typeof(TCommand).GetProperty(propertyName)?.SetMethod ?? throw new InvalidOperationException($"No setter for property {typeof(TCommand)}.{propertyName} found"); setter.Invoke(first, [firstValue]); diff --git a/AtemSharp.Tests/ModuleInitializer.cs b/AtemSharp.Tests/ModuleInitializer.cs new file mode 100644 index 0000000..6232775 --- /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/VerifySnapshots/BatchOperationTests.Creation_LoadsFromSwitcher.verified.txt b/AtemSharp.Tests/VerifySnapshots/BatchOperationTests.Creation_LoadsFromSwitcher.verified.txt new file mode 100644 index 0000000..a937b10 --- /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/Batch/BatchOperation.cs b/AtemSharp/Batch/BatchOperation.cs index 03b2c0c..5ff0a59 100644 --- a/AtemSharp/Batch/BatchOperation.cs +++ b/AtemSharp/Batch/BatchOperation.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using AtemSharp.Commands; using AtemSharp.State; using AtemSharp.State.Macro; @@ -23,9 +24,11 @@ public BatchOperation(IBatchLike source) } /// + [ExcludeFromCodeCoverage(Justification = "Auto-Properties aren't tested")] public AtemState State { get; } /// + [ExcludeFromCodeCoverage(Justification = "Auto-Properties aren't tested")] public MacroSystem Macros { get; } /// @@ -53,6 +56,11 @@ public async Task SendCommandsAsync(IEnumerable commands) /// public async Task CommitAsync() { + if (_commandsToSend.Count == 0) + { + return; + } + await _source.SendCommandsAsync(_commandsToSend); _commandsToSend.Clear(); } diff --git a/AtemSharp/State/Macro/MacroPlayer.cs b/AtemSharp/State/Macro/MacroPlayer.cs index 6e4f63c..ceae197 100644 --- a/AtemSharp/State/Macro/MacroPlayer.cs +++ b/AtemSharp/State/Macro/MacroPlayer.cs @@ -45,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 33788c3..d618d06 100644 --- a/AtemSharp/State/Macro/MacroRecorder.cs +++ b/AtemSharp/State/Macro/MacroRecorder.cs @@ -59,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 6928617..7efc705 100644 --- a/AtemSharp/State/Macro/MacroSystem.cs +++ b/AtemSharp/State/Macro/MacroSystem.cs @@ -14,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]); + } } From 4b3c0b6940aa063dcbad6e7196fbae28b10b6962 Mon Sep 17 00:00:00 2001 From: Markus Palcer Date: Sun, 11 Jan 2026 09:44:01 +0100 Subject: [PATCH 9/9] add missing coverage --- AtemSharp.Tests/Batch/BatchOperationTests.cs | 33 ------------------- AtemSharp.Tests/Batch/MergeableCommand.cs | 21 ++++++++++++ AtemSharp.Tests/Batch/NonMergeableCommand.cs | 19 +++++++++++ .../Commands/Macro/MacroActionCommandTests.cs | 23 +++++++++++++ .../Macro/MacroAddTimedPauseCommandTests.cs | 9 +++++ .../Macro/MacroPropertiesCommandTests.cs | 6 ++++ .../Commands/Macro/MacroRecordCommandTests.cs | 6 ++++ .../Macro/MacroRunStatusCommandTests.cs | 6 ++++ .../Commands/SerializedCommandTestBase.cs | 7 ++++ 9 files changed, 97 insertions(+), 33 deletions(-) create mode 100644 AtemSharp.Tests/Batch/MergeableCommand.cs create mode 100644 AtemSharp.Tests/Batch/NonMergeableCommand.cs diff --git a/AtemSharp.Tests/Batch/BatchOperationTests.cs b/AtemSharp.Tests/Batch/BatchOperationTests.cs index aa1fd75..d238cf3 100644 --- a/AtemSharp.Tests/Batch/BatchOperationTests.cs +++ b/AtemSharp.Tests/Batch/BatchOperationTests.cs @@ -1,6 +1,4 @@ using AtemSharp.Batch; -using AtemSharp.Commands; -using AtemSharp.State.Info; using AtemSharp.Tests.TestUtilities; namespace AtemSharp.Tests.Batch; @@ -8,37 +6,6 @@ namespace AtemSharp.Tests.Batch; [TestFixture] public class BatchOperationTests { - private 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; - } - } - - private 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; - } - } - [Test] public async Task QueueDifferentCommands() { diff --git a/AtemSharp.Tests/Batch/MergeableCommand.cs b/AtemSharp.Tests/Batch/MergeableCommand.cs new file mode 100644 index 0000000..6b2e33f --- /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 0000000..55f5564 --- /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 395bf47..bdb7ca4 100644 --- a/AtemSharp.Tests/Commands/Macro/MacroActionCommandTests.cs +++ b/AtemSharp.Tests/Commands/Macro/MacroActionCommandTests.cs @@ -1,5 +1,6 @@ using AtemSharp.Commands.Macro; using AtemSharp.State.Macro; +using AtemSharp.Tests.Batch; using NSubstitute; namespace AtemSharp.Tests.Commands.Macro; @@ -148,4 +149,26 @@ public void DontMergeDeleteIfIndexIsDifferent() 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 7395585..050679f 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; @@ -27,4 +28,12 @@ public void MergeCommand() 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 80ab82b..58ab031 100644 --- a/AtemSharp.Tests/Commands/Macro/MacroPropertiesCommandTests.cs +++ b/AtemSharp.Tests/Commands/Macro/MacroPropertiesCommandTests.cs @@ -102,4 +102,10 @@ public void IfPropertyIsUnchangedOnNewCommand_ItRetainsTheValueOfTheOldCommand(s { 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 3c4e019..795839e 100644 --- a/AtemSharp.Tests/Commands/Macro/MacroRecordCommandTests.cs +++ b/AtemSharp.Tests/Commands/Macro/MacroRecordCommandTests.cs @@ -67,4 +67,10 @@ public void IfPropertyIsUnchangedOnNewCommand_ItRetainsTheValueOfTheOldCommand(s { 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 dfb68fc..dbecf0d 100644 --- a/AtemSharp.Tests/Commands/Macro/MacroRunStatusCommandTests.cs +++ b/AtemSharp.Tests/Commands/Macro/MacroRunStatusCommandTests.cs @@ -29,4 +29,10 @@ public void NewCommandOverridesOldValue(bool first, bool second, bool expected) 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 d021d97..7862192 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; @@ -112,6 +113,12 @@ public void TestPropertyNonMerging( 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) {