Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/SiteCommandDtoMapperGoldenTests.cs
T
Joseph Doherty 59b13d317b feat(grpc): T1B.1 — site_command.proto + SiteCommandDtoMapper + round-trip goldens
Phase 1B's contract slice: the wire shape and the canonical translation for the
28 central→site commands that leave ClusterClient. No behaviour changes yet —
SiteCommunicationActor and CentralCommunicationActor are untouched; the
dispatcher refactor (T1B.2) and the central transport seam (T1B.3) consume this.

Protos/site_command.proto (package scadabridge.sitecommand.v1, service
SiteCommandService): six domain RPCs, each with a `oneof` request/reply
envelope. The grouping is what carries deadline policy — every command inside a
group shares a CommunicationOptions timeout class today, so one RPC per group
keeps the deadline choice in one place on the client and one dispatch switch on
the server, while the oneof keeps each command individually typed:
ExecuteLifecycle(6) · ExecuteOpcUa(8) · ExecuteQuery(4) · ExecuteParked(5) ·
ExecuteRoute(4) · TriggerFailover(1). IntegrationCallRequest — the 29th entry on
SiteCommunicationActor's receive table — is deliberately excluded as dead code
(2026-07-22-integration-call-routing-is-dead-code.md).

Contract decisions worth knowing:

- Nullable COLLECTIONS ride in per-collection wrapper messages
  (DeployArtifactsCommand's six artifact lists, CertTrustResult.Certs,
  RouteToCallRequest.Parameters). proto3 repeated/map collapses null into empty,
  and that distinction is live at the site — the same silent-data-loss class the
  transport round-trip guard exposed in PLAN-05 T8. Goldens cover null, empty
  and populated for each.
- Nullable strings use the empty-string-means-null convention already set by
  AuditEventDtoMapper, with ONE exception: RouteToWaitForAttributeRequest's
  TargetValueEncoded, where "wait for the empty string" is a real target, so it
  carries a StringValue wrapper. Both behaviours are asserted, not assumed.
- Nullable enums ride in one-field messages (proto3 enums have no presence and
  no stock wrapper). Enum translation is an explicit switch in both directions —
  never by ordinal — so reordering a C# enum cannot re-map the wire; every wire
  enum reserves 0 for _UNSPECIFIED and decodes to a documented safe default
  rather than faulting a command from a version-skewed peer.
- New LooseValueCodec carries the surviving `object?` members (script params and
  return values, attribute values, tag read/write values) as a type-tagged union
  so a boxed value keeps its runtime CLR type, as it does today under Akka's
  type-preserving JSON serializer. Dates ride as invariant round-trip strings,
  not Timestamp, which would silently normalise away DateTime.Kind and
  DateTimeOffset.Offset. Lists/maps recurse; anything outside the tagged set
  falls back to JSON and is documented as CLR-type-lossy.
- DebugViewSnapshot gets its own full-fidelity alarm/attribute messages rather
  than reusing sitestream's AlarmStateUpdate, which flattens values to display
  strings — right for a live stream, lossy for a snapshot the UI treats as
  authoritative. The encoder omits an AlarmStateChanged.Condition that already
  equals the record's derived default, so computed alarms round-trip exactly
  (record equality compares the nullable backing field, not the property).

Tests are reflection-driven so the coverage cannot drift: the round-trip theory
enumerates the mapper's own ToProto overloads, the envelope guards enumerate the
generated oneof descriptors, and a missing golden fails the build. 216 new tests
green (Communication 532 total, Commons 684 total, solution build 0/0).

Codegen is checked in under SiteCommandGrpc/ per the sitestream recipe; the
<Protobuf> ItemGroup stays commented out (an active one segfaults protoc in the
linux_arm64 Docker image). docker/regen-proto.sh now handles every proto in that
ItemGroup instead of just sitestream, and re-comments idempotently.
2026-07-22 20:04:23 -04:00

570 lines
26 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 — 29 on
/// <c>SiteCommunicationActor</c>'s receive table minus the dead
/// <c>IntegrationCallRequest</c>. 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><c>IntegrationCallRequest</c> is excluded by design and must be rejected, not silently dropped.</summary>
[Fact]
public void IntegrationCallRequest_IsRejected()
{
var dead = new Commons.Messages.Integration.IntegrationCallRequest(
"corr", "site-a", "Instance", "System", "Method",
new Dictionary<string, object?>(), DateTimeOffset.UtcNow);
Assert.Throws<ArgumentException>(() => SiteCommandDtoMapper.GroupOf(dead));
}
/// <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;
}
}