a5256e9b12
'Pattern 4: Integration Routing' — RouteIntegrationCallAsync → SiteEnvelope(IntegrationCallRequest) → SiteCommunicationActor integration handler — was plumbed end to end but connected at neither end: no producer (zero callers) and no handler (AkkaHostedService never registered LocalHandlerType.Integration). It was an early scaffold the architecture routed around — the brokered External→Central→Site→Central round-trip is served by the Inbound API's routed-site-script path (the RouteTo* verbs, driven from CommunicationServiceInstanceRouter), which is live, tested, and shares IntegrationTimeout. Decision (#32): delete. Removed the IntegrationCall{Request,Response} messages, RouteIntegrationCallAsync, the SiteCommunicationActor receive block + _integrationHandler field + LocalHandlerType.Integration, and the four tests that covered them (2 actor, 2 message-contract, 1 dispatcher- reject, 1 mapper-reject). KEPT IntegrationTimeout — it is the live timeout for the RouteTo* verbs. Updated the exclusion-narrative comments (proto/mapper/dispatcher), design §4, the components doc timeout table, and marked the known-issue RESOLVED. Full solution build clean (0/0); Communication 634 + Host 421 green. Net -172/+20 across 14 files. Not in the gRPC proto (was deliberately excluded there), so no wire-format change.
557 lines
25 KiB
C#
557 lines
25 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Round-trip goldens for <see cref="SiteCommandDtoMapper"/> — the 28 migrated
|
|
/// central→site commands, all 22 reply shapes, and every nested type they
|
|
/// carry, each proven to survive record → proto → record unchanged.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// The suite is REFLECTION-DRIVEN on purpose. Both the per-type theory and the
|
|
/// coverage guards enumerate the mapper's real surface (its <c>ToProto</c>
|
|
/// overloads) and the real proto contract (the generated <c>oneof</c>
|
|
/// 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.
|
|
/// </para>
|
|
/// <para>
|
|
/// 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 <c>Condition</c>) are
|
|
/// asserted explicitly below rather than papered over.
|
|
/// </para>
|
|
/// </remarks>
|
|
public class SiteCommandDtoMapperGoldenTests
|
|
{
|
|
private static readonly IReadOnlyDictionary<string, Type> TypesByName =
|
|
SiteCommandSamples.All.Keys.ToDictionary(t => t.FullName!, t => t);
|
|
|
|
/// <summary>One theory case per (mapped type, golden sample).</summary>
|
|
/// <returns>Type name and sample index pairs covering every golden fixture.</returns>
|
|
public static IEnumerable<object[]> 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
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Every non-enum type the mapper can project must have goldens. This is the
|
|
/// guard that makes the suite self-maintaining.
|
|
/// </summary>
|
|
[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));
|
|
}
|
|
|
|
/// <summary>Every golden sample type must have at least two samples (populated + minimal).</summary>
|
|
[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));
|
|
}
|
|
|
|
/// <summary>
|
|
/// The contract carries exactly 28 commands across six domain groups. If the
|
|
/// site gains a command, this count moves deliberately, not silently.
|
|
/// </summary>
|
|
[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());
|
|
}
|
|
|
|
/// <summary>The contract carries 22 reply shapes across the same six groups.</summary>
|
|
[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());
|
|
}
|
|
|
|
/// <summary>
|
|
/// Packing every command golden must exercise EVERY <c>oneof</c> case
|
|
/// declared in the four multi-command request envelopes. A new proto case
|
|
/// with no producing command fails here.
|
|
/// </summary>
|
|
[Theory]
|
|
[MemberData(nameof(RequestEnvelopes))]
|
|
public void EveryRequestOneofCase_IsProducedByACommand(string envelopeName)
|
|
{
|
|
var produced = CommandSampleTypes()
|
|
.Select(t => PackCommand(Sample(t)))
|
|
.OfType<IMessage>()
|
|
.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));
|
|
}
|
|
|
|
/// <summary>The reply-envelope mirror of <see cref="EveryRequestOneofCase_IsProducedByACommand"/>.</summary>
|
|
[Theory]
|
|
[MemberData(nameof(ReplyEnvelopes))]
|
|
public void EveryReplyOneofCase_IsProducedByAReply(string envelopeName)
|
|
{
|
|
var produced = ReplyTypes()
|
|
.Select(t => PackReply(SampleReply(t)))
|
|
.OfType<IMessage>()
|
|
.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));
|
|
}
|
|
|
|
/// <summary>Names of the four multi-command request envelopes.</summary>
|
|
/// <returns>Envelope message names.</returns>
|
|
public static IEnumerable<object[]> RequestEnvelopes() =>
|
|
[["LifecycleRequest"], ["OpcUaRequest"], ["QueryRequest"], ["ParkedRequest"], ["RouteRequest"]];
|
|
|
|
/// <summary>Names of the four multi-reply reply envelopes.</summary>
|
|
/// <returns>Envelope message names.</returns>
|
|
public static IEnumerable<object[]> ReplyEnvelopes() =>
|
|
[["LifecycleReply"], ["OpcUaReply"], ["QueryReply"], ["ParkedReply"], ["RouteReply"]];
|
|
|
|
/// <summary>Every command golden survives the full envelope pack/unpack, not just its own message.</summary>
|
|
[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)");
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>Every reply golden survives the full envelope pack/unpack.</summary>
|
|
[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)");
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>The fire-and-forget unsubscribe ack is a real envelope case, not a special path.</summary>
|
|
[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)));
|
|
|
|
/// <summary>
|
|
/// An unspecified wire enum — what a peer on an older contract sends — must
|
|
/// decode to the documented safe default instead of faulting the command.
|
|
/// </summary>
|
|
[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
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// …but NOT for a wait target, where the empty string is a real value. This
|
|
/// is why that one field carries a <c>StringValue</c> wrapper.
|
|
/// </summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// A computed alarm leaves <see cref="AlarmStateChanged.Condition"/> 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.
|
|
/// </summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// The flip side of the normalisation: a condition set EXPLICITLY to the value
|
|
/// the record would have derived comes back implicit. The
|
|
/// <see cref="AlarmStateChanged.Condition"/> value is identical — only the
|
|
/// record's private "was it set?" bit differs, which no consumer can observe.
|
|
/// </summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>A native alarm's explicit, non-derived condition is carried verbatim.</summary>
|
|
[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
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
|
|
/// <summary>Packing a command into the wrong group's envelope is a hard error, not a silent no-op.</summary>
|
|
[Fact]
|
|
public void PackingIntoTheWrongGroup_Throws() =>
|
|
Assert.Throws<ArgumentException>(() =>
|
|
SiteCommandDtoMapper.ToLifecycleRequest(new DebugSnapshotRequest("inst", "corr")));
|
|
|
|
/// <summary>An envelope with no oneof set (a newer peer's unknown case) surfaces as a clear failure.</summary>
|
|
[Fact]
|
|
public void UnsetOneof_ThrowsNotSupported() =>
|
|
Assert.Throws<NotSupportedException>(() => 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<Type> 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<Type> CommandSampleTypes() =>
|
|
SiteCommandSamples.All.Keys.Where(IsCommand);
|
|
|
|
/// <summary>
|
|
/// Reply shapes = the mapper's own classification, plus the synthetic
|
|
/// unsubscribe ack, which has no <c>ToProto</c> overload of its own.
|
|
/// </summary>
|
|
private static IEnumerable<Type> 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<object, SiteCommandGroup> 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;
|
|
}
|
|
}
|