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;
}
}