using System.Reflection; using Google.Protobuf; using Google.Protobuf.Reflection; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol; using ZB.MOM.WW.ScadaBridge.Commons.Messages.DebugView; using ZB.MOM.WW.ScadaBridge.Commons.Messages.InboundApi; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management; using ZB.MOM.WW.ScadaBridge.Commons.Messages.RemoteQuery; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming; using ZB.MOM.WW.ScadaBridge.Commons.Types.Alarms; using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; using ZB.MOM.WW.ScadaBridge.Communication.Grpc; namespace ZB.MOM.WW.ScadaBridge.Communication.Tests; /// /// Round-trip goldens for — the 28 migrated /// central→site commands, all 22 reply shapes, and every nested type they /// carry, each proven to survive record → proto → record unchanged. /// /// /// /// The suite is REFLECTION-DRIVEN on purpose. Both the per-type theory and the /// coverage guards enumerate the mapper's real surface (its ToProto /// overloads) and the real proto contract (the generated oneof /// descriptors), so adding a command without a golden fails the build rather /// than quietly shipping an untested field. A hand-maintained list across 50 /// types would drift on the first change. /// /// /// Where a round trip is not bit-exact, the mapper is the thing that gets fixed /// — never the fixture. The two deliberate normalisations /// (empty-string-means-null, and the implicit alarm Condition) are /// asserted explicitly below rather than papered over. /// /// public class SiteCommandDtoMapperGoldenTests { private static readonly IReadOnlyDictionary TypesByName = SiteCommandSamples.All.Keys.ToDictionary(t => t.FullName!, t => t); /// One theory case per (mapped type, golden sample). /// Type name and sample index pairs covering every golden fixture. public static IEnumerable AllSamples() => SiteCommandSamples.All .OrderBy(kv => kv.Key.FullName, StringComparer.Ordinal) .SelectMany(kv => Enumerable.Range(0, kv.Value.Length) .Select(i => new object[] { kv.Key.FullName!, i })); [Theory] [MemberData(nameof(AllSamples))] public void RoundTrips_Unchanged(string typeName, int sampleIndex) { var type = TypesByName[typeName]; var original = SiteCommandSamples.All[type][sampleIndex]; var restored = RoundTrip(original, type); StructuralEquality.AssertDeepEqual(original, restored, type.Name); } // ───────────────────────────────────────────────────────────────────── // Coverage guards // ───────────────────────────────────────────────────────────────────── /// /// Every non-enum type the mapper can project must have goldens. This is the /// guard that makes the suite self-maintaining. /// [Fact] public void EveryMappedType_HasGoldenSamples() { var missing = MappedRecordTypes() .Where(t => !SiteCommandSamples.All.ContainsKey(t)) .Select(t => t.Name) .OrderBy(n => n, StringComparer.Ordinal) .ToList(); Assert.True( missing.Count == 0, "SiteCommandDtoMapper projects types with no golden sample: " + string.Join(", ", missing)); } /// Every golden sample type must have at least two samples (populated + minimal). [Fact] public void EveryGoldenType_HasAtLeastOneSample() { var empty = SiteCommandSamples.All .Where(kv => kv.Value.Length == 0) .Select(kv => kv.Key.Name) .ToList(); Assert.True(empty.Count == 0, "Golden types with no samples: " + string.Join(", ", empty)); } /// /// The contract carries exactly 28 commands across six domain groups. If the /// site gains a command, this count moves deliberately, not silently. /// [Fact] public void CommandInventory_Is28_AcrossSixGroups() { var commands = CommandSampleTypes().ToList(); Assert.Equal(28, commands.Count); Assert.Equal( [6, 8, 4, 5, 4, 1], new[] { SiteCommandGroup.Lifecycle, SiteCommandGroup.OpcUa, SiteCommandGroup.Query, SiteCommandGroup.Parked, SiteCommandGroup.Route, SiteCommandGroup.Failover }.Select(g => commands.Count(t => SiteCommandDtoMapper.GroupOf(Sample(t)) == g)).ToArray()); } /// The contract carries 22 reply shapes across the same six groups. [Fact] public void ReplyInventory_Is22_AcrossSixGroups() { var replies = ReplyTypes().ToList(); Assert.Equal(22, replies.Count); Assert.Equal( [4, 6, 3, 4, 4, 1], new[] { SiteCommandGroup.Lifecycle, SiteCommandGroup.OpcUa, SiteCommandGroup.Query, SiteCommandGroup.Parked, SiteCommandGroup.Route, SiteCommandGroup.Failover }.Select(g => replies.Count(t => SiteCommandDtoMapper.GroupOfReply(SampleReply(t)) == g)).ToArray()); } /// /// Packing every command golden must exercise EVERY oneof case /// declared in the four multi-command request envelopes. A new proto case /// with no producing command fails here. /// [Theory] [MemberData(nameof(RequestEnvelopes))] public void EveryRequestOneofCase_IsProducedByACommand(string envelopeName) { var produced = CommandSampleTypes() .Select(t => PackCommand(Sample(t))) .OfType() .Where(m => m.Descriptor.Name == envelopeName) .Select(OneofCaseName) .ToHashSet(StringComparer.Ordinal); var declared = DescriptorFor(envelopeName).Oneofs[0].Fields .Select(f => f.PropertyName) .ToHashSet(StringComparer.Ordinal); Assert.Equal(declared.OrderBy(n => n, StringComparer.Ordinal), produced.OrderBy(n => n, StringComparer.Ordinal)); } /// The reply-envelope mirror of . [Theory] [MemberData(nameof(ReplyEnvelopes))] public void EveryReplyOneofCase_IsProducedByAReply(string envelopeName) { var produced = ReplyTypes() .Select(t => PackReply(SampleReply(t))) .OfType() .Where(m => m.Descriptor.Name == envelopeName) .Select(OneofCaseName) .ToHashSet(StringComparer.Ordinal); var declared = DescriptorFor(envelopeName).Oneofs[0].Fields .Select(f => f.PropertyName) .ToHashSet(StringComparer.Ordinal); Assert.Equal(declared.OrderBy(n => n, StringComparer.Ordinal), produced.OrderBy(n => n, StringComparer.Ordinal)); } /// Names of the four multi-command request envelopes. /// Envelope message names. public static IEnumerable RequestEnvelopes() => [["LifecycleRequest"], ["OpcUaRequest"], ["QueryRequest"], ["ParkedRequest"], ["RouteRequest"]]; /// Names of the four multi-reply reply envelopes. /// Envelope message names. public static IEnumerable ReplyEnvelopes() => [["LifecycleReply"], ["OpcUaReply"], ["QueryReply"], ["ParkedReply"], ["RouteReply"]]; /// Every command golden survives the full envelope pack/unpack, not just its own message. [Fact] public void EveryCommandGolden_SurvivesItsEnvelope() { foreach (var (type, samples) in SiteCommandSamples.All) { if (!IsCommand(type)) { continue; } foreach (var sample in samples) { var restored = UnpackCommand(PackCommand(sample)); StructuralEquality.AssertDeepEqual(sample, restored, $"{type.Name}(envelope)"); } } } /// Every reply golden survives the full envelope pack/unpack. [Fact] public void EveryReplyGolden_SurvivesItsEnvelope() { foreach (var (type, samples) in SiteCommandSamples.All) { if (!IsReply(type)) { continue; } foreach (var sample in samples) { var restored = UnpackReply(PackReply(sample)); StructuralEquality.AssertDeepEqual(sample, restored, $"{type.Name}(envelope)"); } } } /// The fire-and-forget unsubscribe ack is a real envelope case, not a special path. [Fact] public void UnsubscribeDebugViewAck_RoundTripsThroughQueryReply() { var envelope = SiteCommandDtoMapper.ToQueryReply(UnsubscribeDebugViewAck.Instance); Assert.Equal(QueryReply.ReplyOneofCase.UnsubscribeDebugView, envelope.ReplyCase); Assert.Same(UnsubscribeDebugViewAck.Instance, SiteCommandDtoMapper.FromQueryReply(envelope)); } // ───────────────────────────────────────────────────────────────────── // Enum exhaustiveness // ───────────────────────────────────────────────────────────────────── [Theory] [InlineData(DeploymentStatus.Pending)] [InlineData(DeploymentStatus.InProgress)] [InlineData(DeploymentStatus.Success)] [InlineData(DeploymentStatus.Failed)] public void DeploymentStatus_RoundTrips(DeploymentStatus value) => Assert.Equal(value, SiteCommandDtoMapper.FromProto(SiteCommandDtoMapper.ToProto(value))); [Theory] [InlineData(BrowseNodeClass.Object)] [InlineData(BrowseNodeClass.Variable)] [InlineData(BrowseNodeClass.Method)] [InlineData(BrowseNodeClass.Other)] public void BrowseNodeClass_RoundTrips(BrowseNodeClass value) => Assert.Equal(value, SiteCommandDtoMapper.FromProto(SiteCommandDtoMapper.ToProto(value))); [Theory] [InlineData(BrowseFailureKind.ConnectionNotFound)] [InlineData(BrowseFailureKind.ConnectionNotConnected)] [InlineData(BrowseFailureKind.NotBrowsable)] [InlineData(BrowseFailureKind.Timeout)] [InlineData(BrowseFailureKind.ServerError)] public void BrowseFailureKind_RoundTrips(BrowseFailureKind value) => Assert.Equal(value, SiteCommandDtoMapper.FromProto(SiteCommandDtoMapper.ToProto(value))); [Theory] [InlineData(ReadTagValuesFailureKind.ConnectionNotFound)] [InlineData(ReadTagValuesFailureKind.ConnectionNotConnected)] [InlineData(ReadTagValuesFailureKind.Timeout)] [InlineData(ReadTagValuesFailureKind.ServerError)] public void ReadTagValuesFailureKind_RoundTrips(ReadTagValuesFailureKind value) => Assert.Equal(value, SiteCommandDtoMapper.FromProto(SiteCommandDtoMapper.ToProto(value))); [Theory] [InlineData(VerifyFailureKind.Unreachable)] [InlineData(VerifyFailureKind.AuthFailed)] [InlineData(VerifyFailureKind.UntrustedCertificate)] [InlineData(VerifyFailureKind.Timeout)] [InlineData(VerifyFailureKind.ServerError)] public void VerifyFailureKind_RoundTrips(VerifyFailureKind value) => Assert.Equal(value, SiteCommandDtoMapper.FromProto(SiteCommandDtoMapper.ToProto(value))); [Theory] [InlineData(StoreAndForwardCategory.ExternalSystem)] [InlineData(StoreAndForwardCategory.Notification)] [InlineData(StoreAndForwardCategory.CachedDbWrite)] public void StoreAndForwardCategory_RoundTrips(StoreAndForwardCategory value) => Assert.Equal(value, SiteCommandDtoMapper.FromProto(SiteCommandDtoMapper.ToProto(value))); [Theory] [InlineData(AlarmState.Active)] [InlineData(AlarmState.Normal)] public void AlarmState_RoundTrips(AlarmState value) => Assert.Equal(value, SiteCommandDtoMapper.FromProto(SiteCommandDtoMapper.ToProto(value))); [Theory] [InlineData(AlarmLevel.None)] [InlineData(AlarmLevel.Low)] [InlineData(AlarmLevel.LowLow)] [InlineData(AlarmLevel.High)] [InlineData(AlarmLevel.HighHigh)] public void AlarmLevel_RoundTrips(AlarmLevel value) => Assert.Equal(value, SiteCommandDtoMapper.FromProto(SiteCommandDtoMapper.ToProto(value))); [Theory] [InlineData(AlarmKind.Computed)] [InlineData(AlarmKind.NativeOpcUa)] [InlineData(AlarmKind.NativeMxAccess)] public void AlarmKind_RoundTrips(AlarmKind value) => Assert.Equal(value, SiteCommandDtoMapper.FromProto(SiteCommandDtoMapper.ToProto(value))); [Theory] [InlineData(AlarmShelveState.Unshelved)] [InlineData(AlarmShelveState.OneShotShelved)] [InlineData(AlarmShelveState.TimedShelved)] [InlineData(AlarmShelveState.PermanentShelved)] public void AlarmShelveState_RoundTrips(AlarmShelveState value) => Assert.Equal(value, SiteCommandDtoMapper.FromProto(SiteCommandDtoMapper.ToProto(value))); /// /// An unspecified wire enum — what a peer on an older contract sends — must /// decode to the documented safe default instead of faulting the command. /// [Fact] public void UnspecifiedWireEnums_DecodeToSafeDefaults() { Assert.Equal(DeploymentStatus.Failed, SiteCommandDtoMapper.FromProto(DeploymentStatusDto.Unspecified)); Assert.Equal(BrowseNodeClass.Other, SiteCommandDtoMapper.FromProto(BrowseNodeClassDto.Unspecified)); Assert.Equal(BrowseFailureKind.ServerError, SiteCommandDtoMapper.FromProto(BrowseFailureKindDto.Unspecified)); Assert.Equal( ReadTagValuesFailureKind.ServerError, SiteCommandDtoMapper.FromProto(ReadTagValuesFailureKindDto.Unspecified)); Assert.Equal(VerifyFailureKind.ServerError, SiteCommandDtoMapper.FromProto(VerifyFailureKindDto.Unspecified)); Assert.Equal( StoreAndForwardCategory.ExternalSystem, SiteCommandDtoMapper.FromProto(StoreAndForwardCategoryDto.Unspecified)); Assert.Equal(AlarmState.Normal, SiteCommandDtoMapper.FromProto(AlarmStateDto.Unspecified)); Assert.Equal(AlarmLevel.None, SiteCommandDtoMapper.FromProto(AlarmLevelDto.Unspecified)); Assert.Equal(AlarmKind.Computed, SiteCommandDtoMapper.FromProto(AlarmKindDto.Unspecified)); Assert.Equal(AlarmShelveState.Unshelved, SiteCommandDtoMapper.FromProto(AlarmShelveStateDto.Unspecified)); } // ───────────────────────────────────────────────────────────────────── // The two deliberate normalisations, asserted rather than hidden // ───────────────────────────────────────────────────────────────────── /// /// Nullable strings ride as plain proto3 strings, so an empty one comes back /// as null. Documented in the mapper; asserted here so it stays a choice. /// [Fact] public void EmptyNullableString_NormalisesToNull() { var original = new SiteFailoverAck("corr", Accepted: false, TargetAddress: "", ErrorMessage: ""); var restored = SiteCommandDtoMapper.FromProto(SiteCommandDtoMapper.ToProto(original)); Assert.Null(restored.TargetAddress); Assert.Null(restored.ErrorMessage); } /// /// …but NOT for a wait target, where the empty string is a real value. This /// is why that one field carries a StringValue wrapper. /// [Fact] public void EmptyWaitTarget_StaysDistinctFromNull() { var empty = SiteCommandDtoMapper.FromProto(SiteCommandDtoMapper.ToProto( new RouteToWaitForAttributeRequest("c", "i", "a", "", TimeSpan.FromSeconds(1), DateTimeOffset.UtcNow))); var missing = SiteCommandDtoMapper.FromProto(SiteCommandDtoMapper.ToProto( new RouteToWaitForAttributeRequest("c", "i", "a", null, TimeSpan.FromSeconds(1), DateTimeOffset.UtcNow))); Assert.Equal(string.Empty, empty.TargetValueEncoded); Assert.Null(missing.TargetValueEncoded); } /// /// A computed alarm leaves implicit /// (the record derives it from State + Priority). The encoder omits a /// condition that already equals that derived value, so the record comes back /// byte-for-byte equal — including under record equality, which compares the /// nullable backing field, not the property. /// [Fact] public void ComputedAlarm_KeepsItsImplicitCondition() { var original = new AlarmStateChanged("inst", "alarm", AlarmState.Active, 500, DateTimeOffset.UtcNow); var dto = SiteCommandDtoMapper.ToProto(original); var restored = SiteCommandDtoMapper.FromProto(dto); Assert.Null(dto.Condition); Assert.Equal(original, restored); } /// /// The flip side of the normalisation: a condition set EXPLICITLY to the value /// the record would have derived comes back implicit. The /// value is identical — only the /// record's private "was it set?" bit differs, which no consumer can observe. /// [Fact] public void ExplicitConditionEqualToTheDerivedDefault_NormalisesToImplicit() { var original = new AlarmStateChanged("inst", "alarm", AlarmState.Normal, 100, DateTimeOffset.UtcNow) { Condition = AlarmConditionStateFactory.ForComputed(AlarmState.Normal, 100) }; var restored = SiteCommandDtoMapper.FromProto(SiteCommandDtoMapper.ToProto(original)); Assert.Equal(original.Condition, restored.Condition); } /// A native alarm's explicit, non-derived condition is carried verbatim. [Fact] public void NativeAlarm_KeepsItsExplicitCondition() { var condition = new AlarmConditionState(true, false, true, AlarmShelveState.PermanentShelved, true, 999); var original = new AlarmStateChanged("inst", "alarm", AlarmState.Active, 500, DateTimeOffset.UtcNow) { Kind = AlarmKind.NativeMxAccess, Condition = condition }; var dto = SiteCommandDtoMapper.ToProto(original); var restored = SiteCommandDtoMapper.FromProto(dto); Assert.NotNull(dto.Condition); Assert.Equal(condition, restored.Condition); } // ───────────────────────────────────────────────────────────────────── // Group classification + rejection // ───────────────────────────────────────────────────────────────────── /// Packing a command into the wrong group's envelope is a hard error, not a silent no-op. [Fact] public void PackingIntoTheWrongGroup_Throws() => Assert.Throws(() => SiteCommandDtoMapper.ToLifecycleRequest(new DebugSnapshotRequest("inst", "corr"))); /// An envelope with no oneof set (a newer peer's unknown case) surfaces as a clear failure. [Fact] public void UnsetOneof_ThrowsNotSupported() => Assert.Throws(() => SiteCommandDtoMapper.FromLifecycleRequest(new LifecycleRequest())); // ───────────────────────────────────────────────────────────────────── // Reflection plumbing // ───────────────────────────────────────────────────────────────────── private static object RoundTrip(object original, Type type) { var toProto = typeof(SiteCommandDtoMapper) .GetMethods(BindingFlags.Public | BindingFlags.Static) .Single(m => m.Name == "ToProto" && m.GetParameters() is [{ } p] && p.ParameterType == type); var wire = toProto.Invoke(null, [original])!; var fromProto = typeof(SiteCommandDtoMapper) .GetMethods(BindingFlags.Public | BindingFlags.Static) .Single(m => m.Name == "FromProto" && m.GetParameters() is [{ } p] && p.ParameterType == wire.GetType()); return fromProto.Invoke(null, [wire])!; } private static IEnumerable MappedRecordTypes() => typeof(SiteCommandDtoMapper) .GetMethods(BindingFlags.Public | BindingFlags.Static) .Where(m => m.Name == "ToProto" && m.GetParameters().Length == 1) .Select(m => m.GetParameters()[0].ParameterType) .Where(t => !t.IsEnum) .Distinct(); private static IEnumerable CommandSampleTypes() => SiteCommandSamples.All.Keys.Where(IsCommand); /// /// Reply shapes = the mapper's own classification, plus the synthetic /// unsubscribe ack, which has no ToProto overload of its own. /// private static IEnumerable ReplyTypes() => SiteCommandSamples.All.Keys.Where(IsReply).Append(typeof(UnsubscribeDebugViewAck)); private static bool IsCommand(Type type) => Classifies(type, SiteCommandDtoMapper.GroupOf); private static bool IsReply(Type type) => Classifies(type, SiteCommandDtoMapper.GroupOfReply); private static bool Classifies(Type type, Func classify) { try { classify(SiteCommandSamples.All[type][0]); return true; } catch (ArgumentException) { return false; } } private static object Sample(Type type) => SiteCommandSamples.All[type][0]; private static object SampleReply(Type type) => type == typeof(UnsubscribeDebugViewAck) ? UnsubscribeDebugViewAck.Instance : Sample(type); private static object PackCommand(object command) => SiteCommandDtoMapper.GroupOf(command) switch { SiteCommandGroup.Lifecycle => SiteCommandDtoMapper.ToLifecycleRequest(command), SiteCommandGroup.OpcUa => SiteCommandDtoMapper.ToOpcUaRequest(command), SiteCommandGroup.Query => SiteCommandDtoMapper.ToQueryRequest(command), SiteCommandGroup.Parked => SiteCommandDtoMapper.ToParkedRequest(command), SiteCommandGroup.Route => SiteCommandDtoMapper.ToRouteRequest(command), _ => SiteCommandDtoMapper.ToProto((TriggerSiteFailover)command) }; private static object UnpackCommand(object envelope) => envelope switch { LifecycleRequest r => SiteCommandDtoMapper.FromLifecycleRequest(r), OpcUaRequest r => SiteCommandDtoMapper.FromOpcUaRequest(r), QueryRequest r => SiteCommandDtoMapper.FromQueryRequest(r), ParkedRequest r => SiteCommandDtoMapper.FromParkedRequest(r), RouteRequest r => SiteCommandDtoMapper.FromRouteRequest(r), TriggerSiteFailoverDto d => SiteCommandDtoMapper.FromProto(d), _ => throw new InvalidOperationException($"Unknown request envelope {envelope.GetType().Name}.") }; private static object PackReply(object reply) => SiteCommandDtoMapper.GroupOfReply(reply) switch { SiteCommandGroup.Lifecycle => SiteCommandDtoMapper.ToLifecycleReply(reply), SiteCommandGroup.OpcUa => SiteCommandDtoMapper.ToOpcUaReply(reply), SiteCommandGroup.Query => SiteCommandDtoMapper.ToQueryReply(reply), SiteCommandGroup.Parked => SiteCommandDtoMapper.ToParkedReply(reply), SiteCommandGroup.Route => SiteCommandDtoMapper.ToRouteReply(reply), _ => SiteCommandDtoMapper.ToProto((SiteFailoverAck)reply) }; private static object UnpackReply(object envelope) => envelope switch { LifecycleReply r => SiteCommandDtoMapper.FromLifecycleReply(r), OpcUaReply r => SiteCommandDtoMapper.FromOpcUaReply(r), QueryReply r => SiteCommandDtoMapper.FromQueryReply(r), ParkedReply r => SiteCommandDtoMapper.FromParkedReply(r), RouteReply r => SiteCommandDtoMapper.FromRouteReply(r), SiteFailoverAckDto d => SiteCommandDtoMapper.FromProto(d), _ => throw new InvalidOperationException($"Unknown reply envelope {envelope.GetType().Name}.") }; private static MessageDescriptor DescriptorFor(string name) => SiteCommandReflection.Descriptor.MessageTypes.Single(m => m.Name == name); private static string OneofCaseName(IMessage envelope) { var oneof = envelope.Descriptor.Oneofs[0]; var field = oneof.Accessor.GetCaseFieldDescriptor(envelope); Assert.NotNull(field); return field.PropertyName; } }