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.
This commit is contained in:
@@ -0,0 +1,153 @@
|
||||
using System.Text.Json;
|
||||
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Communication.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Goldens for <see cref="LooseValueCodec"/> — the type-tagged carrier for the
|
||||
/// <c>object?</c> members of the site command plane (script parameters and
|
||||
/// return values, attribute values, tag reads/writes).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The contract these tests pin down is that a boxed value keeps its RUNTIME
|
||||
/// CLR TYPE across the wire, not merely its printed form. Today's Akka JSON
|
||||
/// serializer preserves it, and an operator-facing tag value that silently
|
||||
/// turns from <c>int</c> into <c>long</c> (or from <c>DateTime</c> into a
|
||||
/// UTC-normalised copy) is a behaviour change, not a refactor.
|
||||
/// </remarks>
|
||||
public class LooseValueCodecTests
|
||||
{
|
||||
public static IEnumerable<object[]> TaggedScalars() =>
|
||||
[
|
||||
[true],
|
||||
[false],
|
||||
[42],
|
||||
[-1],
|
||||
[long.MaxValue],
|
||||
[3.5d],
|
||||
[1.5f],
|
||||
["a string"],
|
||||
[string.Empty],
|
||||
[12.3456789m],
|
||||
[new DateTime(2026, 7, 22, 1, 2, 3, DateTimeKind.Utc)],
|
||||
[new DateTimeOffset(2026, 7, 22, 1, 2, 3, TimeSpan.FromHours(-5))],
|
||||
[Guid.Parse("11111111-2222-3333-4444-555555555555")],
|
||||
[TimeSpan.FromMinutes(90)],
|
||||
[new byte[] { 1, 2, 3 }]
|
||||
];
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(TaggedScalars))]
|
||||
public void TaggedValues_KeepTheirClrType(object value)
|
||||
{
|
||||
var restored = LooseValueCodec.FromProto(LooseValueCodec.ToProto(value));
|
||||
|
||||
Assert.NotNull(restored);
|
||||
Assert.Equal(value.GetType(), restored.GetType());
|
||||
StructuralEquality.AssertDeepEqual(value, restored, value.GetType().Name);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A local <see cref="DateTime"/> must come back local, and an offset
|
||||
/// <see cref="DateTimeOffset"/> must come back with the same offset — the
|
||||
/// reason these ride as round-trip strings instead of proto timestamps.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void DateValues_KeepKindAndOffset()
|
||||
{
|
||||
var local = new DateTime(2026, 7, 22, 1, 2, 3, DateTimeKind.Local);
|
||||
var offset = new DateTimeOffset(2026, 7, 22, 1, 2, 3, TimeSpan.FromHours(5.5));
|
||||
|
||||
var restoredLocal = Assert.IsType<DateTime>(LooseValueCodec.FromProto(LooseValueCodec.ToProto(local)));
|
||||
var restoredOffset =
|
||||
Assert.IsType<DateTimeOffset>(LooseValueCodec.FromProto(LooseValueCodec.ToProto(offset)));
|
||||
|
||||
Assert.Equal(DateTimeKind.Local, restoredLocal.Kind);
|
||||
Assert.Equal(local, restoredLocal);
|
||||
Assert.Equal(TimeSpan.FromHours(5.5), restoredOffset.Offset);
|
||||
}
|
||||
|
||||
/// <summary>An unset carrier and an explicit null marker both decode to null.</summary>
|
||||
[Fact]
|
||||
public void NullIsCarriedTwoWays()
|
||||
{
|
||||
Assert.Null(LooseValueCodec.FromProto(null));
|
||||
Assert.Null(LooseValueCodec.FromProto(LooseValueCodec.ToProto(null)));
|
||||
Assert.Null(LooseValueCodec.ToProtoOrNull(null));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A dictionary entry whose value is null stays distinct from an absent key —
|
||||
/// the reason <c>LooseNull</c> exists at all.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void NullMapValue_SurvivesAsAPresentKey()
|
||||
{
|
||||
var original = new Dictionary<string, object?> { ["present"] = null };
|
||||
|
||||
var restored = LooseValueCodec.FromProtoMap(LooseValueCodec.ToProtoMap(original));
|
||||
|
||||
Assert.True(restored.ContainsKey("present"));
|
||||
Assert.Null(restored["present"]);
|
||||
}
|
||||
|
||||
/// <summary>A null dictionary stays null; an empty one stays empty.</summary>
|
||||
[Fact]
|
||||
public void NullMap_StaysDistinctFromEmptyMap()
|
||||
{
|
||||
Assert.Null(LooseValueCodec.ToProtoMapOrNull(null));
|
||||
Assert.Null(LooseValueCodec.FromProtoMapOrNull(null));
|
||||
|
||||
var empty = LooseValueCodec.FromProtoMapOrNull(
|
||||
LooseValueCodec.ToProtoMapOrNull(new Dictionary<string, object?>()));
|
||||
|
||||
Assert.NotNull(empty);
|
||||
Assert.Empty(empty);
|
||||
}
|
||||
|
||||
/// <summary>Nested lists and maps recurse, so element values keep their types too.</summary>
|
||||
[Fact]
|
||||
public void NestedCollections_KeepElementTypes()
|
||||
{
|
||||
object original = new List<object?>
|
||||
{
|
||||
1,
|
||||
"two",
|
||||
null,
|
||||
new Dictionary<string, object?> { ["deep"] = 3.5d }
|
||||
};
|
||||
|
||||
var restored = LooseValueCodec.FromProto(LooseValueCodec.ToProto(original));
|
||||
|
||||
StructuralEquality.AssertDeepEqual(original, restored, "list");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The documented widening: a typed collection keeps its ELEMENTS' types but
|
||||
/// the container comes back as <c>List<object?></c>. Recorded here so
|
||||
/// the behaviour is a decision rather than a surprise.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void TypedList_WidensToObjectList()
|
||||
{
|
||||
var restored = LooseValueCodec.FromProto(LooseValueCodec.ToProto(new List<int> { 1, 2, 3 }));
|
||||
|
||||
var list = Assert.IsType<List<object?>>(restored);
|
||||
Assert.Equal([1, 2, 3], list.Cast<int>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The documented lossy escape hatch: a value outside the tagged set keeps its
|
||||
/// DATA but not its CLR identity — it decodes as a <see cref="JsonElement"/>.
|
||||
/// Nothing on the command plane carries such a value today; the fallback
|
||||
/// exists so an unexpected one cannot fault a command.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void UntaggedValue_FallsBackToJsonAndLosesItsClrType()
|
||||
{
|
||||
var restored = LooseValueCodec.FromProto(LooseValueCodec.ToProto((byte)7));
|
||||
|
||||
var element = Assert.IsType<JsonElement>(restored);
|
||||
Assert.Equal("7", element.GetRawText());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,569 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Artifacts;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.DataConnection;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.DebugView;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Deployment;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.InboundApi;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Lifecycle;
|
||||
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;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Alarms;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Communication.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Golden fixtures for the site-command round-trip suite: for every command,
|
||||
/// reply and nested type the mapper handles, at least one MAXIMALLY populated
|
||||
/// sample and one MINIMAL sample (every nullable null, every optional
|
||||
/// collection empty or absent).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The two-sample rule is the point. A single "typical" sample proves only that
|
||||
/// the happy path survives; the pairs are what catch a dropped optional field or
|
||||
/// a null/empty collapse — the class of silent data loss the transport
|
||||
/// round-trip guard exposed in PLAN-05 T8, which per-field unit tests had all
|
||||
/// missed.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <see cref="SiteCommandDtoMapperGoldenTests"/> drives this table by
|
||||
/// reflection and FAILS when the mapper gains a type that has no sample here, so
|
||||
/// the coverage cannot silently drift as commands are added.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal static class SiteCommandSamples
|
||||
{
|
||||
private static readonly DateTimeOffset T1 = new(2026, 7, 22, 13, 45, 12, 345, TimeSpan.Zero);
|
||||
private static readonly DateTimeOffset T2 = new(2026, 1, 2, 3, 4, 5, TimeSpan.Zero);
|
||||
private static readonly DateTime D1 = new(2026, 3, 4, 5, 6, 7, DateTimeKind.Utc);
|
||||
private static readonly DateTime D2 = new(2027, 3, 4, 5, 6, 7, DateTimeKind.Utc);
|
||||
private static readonly Guid G1 = Guid.Parse("11111111-2222-3333-4444-555555555555");
|
||||
|
||||
/// <summary>
|
||||
/// Every CLR type the mapper round-trips, mapped to its golden samples.
|
||||
/// Enums are excluded — they get their own exhaustive test.
|
||||
/// </summary>
|
||||
public static IReadOnlyDictionary<Type, object[]> All { get; } = Build();
|
||||
|
||||
private static Dictionary<Type, object[]> Build()
|
||||
{
|
||||
var samples = new Dictionary<Type, object[]>();
|
||||
|
||||
void Add<T>(params T[] values) where T : notnull =>
|
||||
samples[typeof(T)] = values.Cast<object>().ToArray();
|
||||
|
||||
// ── Lifecycle commands ──
|
||||
Add(new RefreshDeploymentCommand(
|
||||
"dep-1", "Site1.Pump1", "hash-abc", "multi-role", T1, "http://central:5000", "tok-1"));
|
||||
|
||||
Add(new EnableInstanceCommand("cmd-1", "Site1.Pump1", T1));
|
||||
Add(new DisableInstanceCommand("cmd-2", "Site1.Pump1", T2));
|
||||
Add(new DeleteInstanceCommand("cmd-3", "Site1.Pump1", T1));
|
||||
Add(new DeploymentStateQueryRequest("corr-1", "Site1.Pump1", T1));
|
||||
|
||||
Add(
|
||||
// Full: every artifact collection populated.
|
||||
new DeployArtifactsCommand(
|
||||
"dep-2",
|
||||
[SharedScript(), new SharedScriptArtifact("s2", "return 2;", null, null)],
|
||||
[ExternalSystem()],
|
||||
[new DatabaseConnectionArtifact("db", "Server=x;", 3, TimeSpan.FromSeconds(7.5))],
|
||||
[new NotificationListArtifact("ops", ["a@x.com", "b@x.com"])],
|
||||
[DataConnection()],
|
||||
[Smtp()],
|
||||
T1),
|
||||
// Minimal: every collection NULL — must not come back as empty lists.
|
||||
new DeployArtifactsCommand("dep-3", null, null, null, null, null, null, T2),
|
||||
// Boundary: every collection present but EMPTY — must not come back null.
|
||||
new DeployArtifactsCommand("dep-4", [], [], [], [], [], [], T1));
|
||||
|
||||
Add(SharedScript(), new SharedScriptArtifact("bare", "", null, null));
|
||||
Add(ExternalSystem(), new ExternalSystemArtifact("bare", "http://x", "None", null, null, 0));
|
||||
Add(new DatabaseConnectionArtifact("db", "Server=x;", 3, TimeSpan.FromMilliseconds(1500)),
|
||||
new DatabaseConnectionArtifact("db2", "", 0, TimeSpan.Zero));
|
||||
Add(new NotificationListArtifact("ops", ["a@x.com"]), new NotificationListArtifact("empty", []));
|
||||
Add(DataConnection(), new DataConnectionArtifact("bare", "OpcUa", null, null, 0));
|
||||
Add(Smtp(), new SmtpConfigurationArtifact("bare", "smtp", 25, "None", "f@x.com", null, null, null));
|
||||
|
||||
// ── Lifecycle replies ──
|
||||
Add(new DeploymentStatusResponse("dep-1", "Site1.Pump1", DeploymentStatus.Success, null, T1),
|
||||
new DeploymentStatusResponse("dep-1", "Site1.Pump1", DeploymentStatus.Failed, "boom", T2));
|
||||
Add(new InstanceLifecycleResponse("cmd-1", "Site1.Pump1", true, null, T1),
|
||||
new InstanceLifecycleResponse("cmd-1", "Site1.Pump1", false, "nope", T2));
|
||||
Add(new DeploymentStateQueryResponse("corr-1", "Site1.Pump1", true, "dep-1", "hash-abc", T1),
|
||||
new DeploymentStateQueryResponse("corr-1", "Site1.Pump1", false, null, null, T2));
|
||||
Add(new ArtifactDeploymentResponse("dep-2", "site-a", true, null, T1),
|
||||
new ArtifactDeploymentResponse("dep-2", "site-a", false, "handler missing", T2));
|
||||
|
||||
// ── OPC UA commands ──
|
||||
Add(new BrowseNodeCommand("conn", "ns=2;s=Root", "cursor-1", "site-a"),
|
||||
new BrowseNodeCommand("conn", null, null, null));
|
||||
Add(new SearchAddressSpaceCommand("conn", "pump", 4, 200, "site-a"),
|
||||
new SearchAddressSpaceCommand("conn", "", 0, 0, null));
|
||||
Add(new ReadTagValuesCommand("conn", ["a", "b"]), new ReadTagValuesCommand("conn", []));
|
||||
Add(new VerifyEndpointCommand("conn", "OpcUa", "{\"url\":\"opc.tcp://x\"}", "site-a"),
|
||||
new VerifyEndpointCommand("conn", "OpcUa", "{}", null));
|
||||
Add(new TrustServerCertCommand("conn", "ZGVy", "AABB", "site-a"),
|
||||
new TrustServerCertCommand("conn", "ZGVy", "AABB", null));
|
||||
Add(new ListServerCertsCommand("site-a"), new ListServerCertsCommand(null));
|
||||
Add(new RemoveServerCertCommand("AABB", "site-a"), new RemoveServerCertCommand("AABB", null));
|
||||
Add(new WriteTagRequest("corr-2", "conn", "ns=2;s=Speed", 42.5d, T1),
|
||||
new WriteTagRequest("corr-2", "conn", "ns=2;s=Speed", null, T2),
|
||||
new WriteTagRequest("corr-2", "conn", "ns=2;s=Flag", true, T1),
|
||||
new WriteTagRequest("corr-2", "conn", "ns=2;s=Name", "manual", T1));
|
||||
|
||||
// ── OPC UA replies ──
|
||||
Add(new BrowseNodeResult([Node(), NodeBare()], true, null, "cursor-2"),
|
||||
new BrowseNodeResult([], false, new BrowseFailure(BrowseFailureKind.Timeout, "timed out"), null));
|
||||
Add(Node(), NodeBare());
|
||||
Add(new BrowseFailure(BrowseFailureKind.ConnectionNotConnected, "not connected"),
|
||||
new BrowseFailure(BrowseFailureKind.ServerError, ""));
|
||||
Add(new SearchAddressSpaceResult([new AddressSpaceMatch(Node(), "/Root/Pump1")], true, null),
|
||||
new SearchAddressSpaceResult([], false, new BrowseFailure(BrowseFailureKind.NotBrowsable, "no")));
|
||||
Add(new AddressSpaceMatch(Node(), "/Root/Pump1"), new AddressSpaceMatch(NodeBare(), ""));
|
||||
Add(new ReadTagValuesResult([Outcome(), OutcomeFailed()], null),
|
||||
new ReadTagValuesResult([], new ReadTagValuesFailure(ReadTagValuesFailureKind.ConnectionNotFound, "gone")));
|
||||
Add(Outcome(), OutcomeFailed());
|
||||
Add(new ReadTagValuesFailure(ReadTagValuesFailureKind.Timeout, "slow"),
|
||||
new ReadTagValuesFailure(ReadTagValuesFailureKind.ServerError, ""));
|
||||
Add(new VerifyEndpointResult(true, null, null, null),
|
||||
new VerifyEndpointResult(false, VerifyFailureKind.UntrustedCertificate, "untrusted", Cert()),
|
||||
new VerifyEndpointResult(false, VerifyFailureKind.Unreachable, "refused", null));
|
||||
Add(Cert());
|
||||
Add(new CertTrustResult(true, null, [Trusted(), TrustedRejected()]),
|
||||
new CertTrustResult(true, null, null),
|
||||
new CertTrustResult(false, "partial failure", []));
|
||||
Add(Trusted(), TrustedRejected());
|
||||
Add(new WriteTagResponse("corr-2", true, null, T1),
|
||||
new WriteTagResponse("corr-2", false, "denied", T2));
|
||||
|
||||
// ── Query commands ──
|
||||
Add(new EventLogQueryRequest(
|
||||
"corr-3", "site-a", T1, T2, "Lifecycle", "Warning", "Site1.Pump1", "restart", "cursor-3", 50, T1),
|
||||
new EventLogQueryRequest("corr-3", "site-a", null, null, null, null, null, null, null, 25, T2));
|
||||
Add(new DebugSnapshotRequest("Site1.Pump1", "corr-4"));
|
||||
Add(new SubscribeDebugViewRequest("Site1.Pump1", "corr-5"));
|
||||
Add(new UnsubscribeDebugViewRequest("Site1.Pump1", "corr-6"));
|
||||
|
||||
// ── Query replies ──
|
||||
Add(new EventLogQueryResponse("corr-3", "site-a", [Entry(), EntryBare()], "cursor-4", true, true, null, T1),
|
||||
new EventLogQueryResponse("corr-3", "site-a", [], null, false, false, "handler missing", T2));
|
||||
Add(Entry(), EntryBare());
|
||||
Add(new DebugViewSnapshot("Site1.Pump1", [AttrValue(), AttrValueNull()], [ComputedAlarm(), NativeAlarm()], T1),
|
||||
new DebugViewSnapshot("Site1.Pump1", [], [], T2, InstanceNotFound: true));
|
||||
Add(AttrValue(), AttrValueNull(), AttrValueList());
|
||||
Add(ComputedAlarm(), NativeAlarm());
|
||||
Add(new AlarmConditionState(true, false, true, AlarmShelveState.TimedShelved, true, 900),
|
||||
new AlarmConditionState(false, true, null, AlarmShelveState.Unshelved, false, 0));
|
||||
|
||||
// ── Parked commands ──
|
||||
Add(new ParkedMessageQueryRequest("corr-7", "site-a", 2, 25, T1));
|
||||
Add(new ParkedMessageRetryRequest("corr-8", "site-a", "msg-1", T1));
|
||||
Add(new ParkedMessageDiscardRequest("corr-9", "site-a", "msg-1", T2));
|
||||
Add(new RetryParkedOperation("corr-10", new TrackedOperationId(G1)),
|
||||
new RetryParkedOperation("corr-10", default));
|
||||
Add(new DiscardParkedOperation("corr-11", new TrackedOperationId(G1)),
|
||||
new DiscardParkedOperation("corr-11", default));
|
||||
|
||||
// ── Parked replies ──
|
||||
Add(new ParkedMessageQueryResponse("corr-7", "site-a", [Parked(), ParkedBare()], 2, 1, 25, true, null, T1),
|
||||
new ParkedMessageQueryResponse("corr-7", "site-a", [], 0, 1, 25, false, "handler missing", T2));
|
||||
Add(Parked(), ParkedBare());
|
||||
Add(new ParkedMessageRetryResponse("corr-8", true),
|
||||
new ParkedMessageRetryResponse("corr-8", false, "not parked"));
|
||||
Add(new ParkedMessageDiscardResponse("corr-9", true),
|
||||
new ParkedMessageDiscardResponse("corr-9", false, "not parked"));
|
||||
Add(new ParkedOperationActionAck("corr-10", true),
|
||||
new ParkedOperationActionAck("corr-10", false, "handler missing"));
|
||||
|
||||
// ── Route commands ──
|
||||
Add(new RouteToCallRequest("corr-12", "Site1.Pump1", "Start", Parameters(), T1, G1),
|
||||
new RouteToCallRequest("corr-12", "Site1.Pump1", "Start", null, T2),
|
||||
new RouteToCallRequest("corr-12", "Site1.Pump1", "Start", new Dictionary<string, object?>(), T1));
|
||||
Add(new RouteToGetAttributesRequest("corr-13", "Site1.Pump1", ["Speed", "Flow"], T1, G1),
|
||||
new RouteToGetAttributesRequest("corr-13", "Site1.Pump1", [], T2));
|
||||
Add(new RouteToSetAttributesRequest(
|
||||
"corr-14", "Site1.Pump1", new Dictionary<string, string> { ["Speed"] = "10" }, T1, G1),
|
||||
new RouteToSetAttributesRequest("corr-14", "Site1.Pump1", new Dictionary<string, string>(), T2));
|
||||
Add(new RouteToWaitForAttributeRequest(
|
||||
"corr-15", "Site1.Pump1", "Speed", "10", TimeSpan.FromSeconds(30), T1, G1, true),
|
||||
new RouteToWaitForAttributeRequest(
|
||||
"corr-15", "Site1.Pump1", "Speed", null, TimeSpan.Zero, T2),
|
||||
// "" is a legitimate wait target and must NOT collapse to null.
|
||||
new RouteToWaitForAttributeRequest(
|
||||
"corr-15", "Site1.Pump1", "Speed", "", TimeSpan.FromMinutes(1), T1));
|
||||
|
||||
// ── Route replies ──
|
||||
Add(new RouteToCallResponse("corr-12", true, 17L, null, T1),
|
||||
new RouteToCallResponse("corr-12", false, null, "script faulted", T2));
|
||||
Add(new RouteToGetAttributesResponse("corr-13", Parameters(), true, null, T1),
|
||||
new RouteToGetAttributesResponse("corr-13", new Dictionary<string, object?>(), false, "no instance", T2));
|
||||
Add(new RouteToSetAttributesResponse("corr-14", true, null, T1),
|
||||
new RouteToSetAttributesResponse("corr-14", false, "locked", T2));
|
||||
Add(new RouteToWaitForAttributeResponse("corr-15", true, 10, "Good", false, true, null, T1),
|
||||
new RouteToWaitForAttributeResponse("corr-15", false, null, null, true, true, null, T2));
|
||||
|
||||
// ── Failover ──
|
||||
Add(new TriggerSiteFailover("corr-16", "site-a"));
|
||||
Add(new SiteFailoverAck("corr-16", true, "akka.tcp://scadabridge@node-b:8081", null),
|
||||
new SiteFailoverAck("corr-16", false, null, "no standby available"));
|
||||
|
||||
return samples;
|
||||
}
|
||||
|
||||
private static SharedScriptArtifact SharedScript() =>
|
||||
new("Calc", "return 1;", "{\"p\":\"int\"}", "{\"r\":\"int\"}");
|
||||
|
||||
private static ExternalSystemArtifact ExternalSystem() =>
|
||||
new("Mes", "https://mes/api", "ApiKey", "{\"key\":\"x\"}", "[{\"name\":\"Post\"}]", 45);
|
||||
|
||||
private static DataConnectionArtifact DataConnection() =>
|
||||
new("Plc1", "OpcUa", "{\"url\":\"opc.tcp://a\"}", "{\"url\":\"opc.tcp://b\"}", 5);
|
||||
|
||||
private static SmtpConfigurationArtifact Smtp() =>
|
||||
new("Default", "smtp.host", 587, "Basic", "from@x.com", "user", "secret", "{\"tenant\":\"t\"}");
|
||||
|
||||
private static BrowseNode Node() => new("ns=2;s=Pump1.Speed", "Speed", BrowseNodeClass.Variable, false, "Double", -1, true);
|
||||
|
||||
private static BrowseNode NodeBare() => new("ns=2;s=Root", "Root", BrowseNodeClass.Object, true);
|
||||
|
||||
private static TagReadOutcome Outcome() => new("ns=2;s=Speed", true, 12.5d, "Good", T1, null);
|
||||
|
||||
private static TagReadOutcome OutcomeFailed() => new("ns=2;s=Bad", false, null, "Bad", T2, "no such node");
|
||||
|
||||
private static ServerCertInfo Cert() => new("AABB", "CN=server", "CN=issuer", D1, D2, "ZGVy");
|
||||
|
||||
private static TrustedCertInfo Trusted() => new("AABB", "CN=server", "CN=issuer", D1, D2, false);
|
||||
|
||||
private static TrustedCertInfo TrustedRejected() => new("CCDD", "CN=other", "CN=issuer", D1, D2, true);
|
||||
|
||||
private static EventLogEntry Entry() =>
|
||||
new(G1.ToString("D"), T1, "Lifecycle", "Warning", "Site1.Pump1", "InstanceActor", "restarted", "{\"n\":1}");
|
||||
|
||||
private static EventLogEntry EntryBare() =>
|
||||
new(Guid.Empty.ToString("D"), T2, "System", "Info", null, "Host", "started", null);
|
||||
|
||||
private static ParkedMessageEntry Parked() =>
|
||||
new("msg-1", "Mes", "Post", "500 from server", 7, T1, T2, 10, StoreAndForwardCategory.CachedDbWrite, "Site1.Pump1");
|
||||
|
||||
private static ParkedMessageEntry ParkedBare() =>
|
||||
new("msg-2", "Mes", "Post", "", 0, T2, T2);
|
||||
|
||||
private static AttributeValueChanged AttrValue() =>
|
||||
new("Site1.Pump1", "Pump1.Speed", "Speed", 12.5d, "Good", T1);
|
||||
|
||||
private static AttributeValueChanged AttrValueNull() =>
|
||||
new("Site1.Pump1", "Pump1.Speed", "Speed", null, "Bad", T2);
|
||||
|
||||
private static AttributeValueChanged AttrValueList() =>
|
||||
new("Site1.Pump1", "Pump1.Trend", "Trend", new List<object?> { 1, 2.5d, "x", null }, "Good", T1);
|
||||
|
||||
/// <summary>A computed alarm: <c>Condition</c> left implicit, which is the common case.</summary>
|
||||
private static AlarmStateChanged ComputedAlarm() =>
|
||||
new("Site1.Pump1", "HighSpeed", AlarmState.Active, 700, T1)
|
||||
{
|
||||
Level = AlarmLevel.HighHigh,
|
||||
Message = "Speed critically high"
|
||||
};
|
||||
|
||||
/// <summary>A mirrored native alarm: every native enrichment field populated, <c>Condition</c> explicit.</summary>
|
||||
private static AlarmStateChanged NativeAlarm() =>
|
||||
new("Site1.Pump1", "Tank01.Level.HiHi", AlarmState.Active, 850, T2)
|
||||
{
|
||||
Level = AlarmLevel.None,
|
||||
Message = "Level high",
|
||||
Kind = AlarmKind.NativeOpcUa,
|
||||
Condition = new AlarmConditionState(true, false, false, AlarmShelveState.OneShotShelved, true, 850),
|
||||
SourceReference = "Tank01.Level.HiHi",
|
||||
AlarmTypeName = "AnalogLimitAlarm.HiHi",
|
||||
Category = "Process",
|
||||
OperatorUser = "multi-role",
|
||||
OperatorComment = "ack'd at panel",
|
||||
OriginalRaiseTime = T1,
|
||||
CurrentValue = "91.2",
|
||||
LimitValue = "90.0",
|
||||
NativeSourceCanonicalName = "Tank01.TankAlarms",
|
||||
IsConfiguredPlaceholder = true
|
||||
};
|
||||
|
||||
private static Dictionary<string, object?> Parameters() => new()
|
||||
{
|
||||
["count"] = 3,
|
||||
["ratio"] = 1.25d,
|
||||
["name"] = "batch-1",
|
||||
["enabled"] = true,
|
||||
["missing"] = null,
|
||||
["when"] = T1,
|
||||
["id"] = G1,
|
||||
["items"] = new List<object?> { 1L, "two", null },
|
||||
["nested"] = new Dictionary<string, object?> { ["inner"] = 5f }
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
using System.Collections;
|
||||
using System.Reflection;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Communication.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Structural deep-equality assertion for the site-command round-trip goldens.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Record equality is NOT usable here. A C# record's generated <c>Equals</c>
|
||||
/// compares members with <c>EqualityComparer<T>.Default</c>, which for
|
||||
/// <c>IReadOnlyList<T></c> / <c>IReadOnlyDictionary<,></c> members
|
||||
/// degrades to reference equality — so <c>Assert.Equal(dto, roundTripped)</c>
|
||||
/// would fail on every collection-bearing message even when the mapper is
|
||||
/// perfect, and (worse) would pass vacuously nowhere useful. This walker
|
||||
/// compares by shape instead: dictionaries by key, sequences element-wise, and
|
||||
/// everything else property-by-property down to leaf values.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The failure message carries the full property path, so a dropped field
|
||||
/// reports as e.g. <c>DeployArtifactsCommand.ExternalSystems[0].TimeoutSeconds</c>
|
||||
/// rather than an opaque "objects differ".
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal static class StructuralEquality
|
||||
{
|
||||
/// <summary>Asserts two object graphs are structurally identical.</summary>
|
||||
/// <param name="expected">The original value.</param>
|
||||
/// <param name="actual">The value that came back through the mapper.</param>
|
||||
/// <param name="rootName">Name used as the root of the reported property path.</param>
|
||||
public static void AssertDeepEqual(object? expected, object? actual, string rootName)
|
||||
=> Compare(expected, actual, rootName);
|
||||
|
||||
private static void Compare(object? expected, object? actual, string path)
|
||||
{
|
||||
if (expected is null && actual is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (expected is null || actual is null)
|
||||
{
|
||||
Assert.Fail($"{path}: expected {Describe(expected)} but got {Describe(actual)}.");
|
||||
return;
|
||||
}
|
||||
|
||||
// JsonElement is the documented lossy escape hatch of LooseValueCodec; it
|
||||
// has no useful Equals, so compare the canonical JSON text.
|
||||
if (expected is JsonElement expectedJson && actual is JsonElement actualJson)
|
||||
{
|
||||
Assert.Equal(expectedJson.GetRawText(), actualJson.GetRawText());
|
||||
return;
|
||||
}
|
||||
|
||||
// Collections are compared by CONTENT, not by concrete container type.
|
||||
// Members are declared IReadOnlyList<T>/IReadOnlyDictionary<,>, so the
|
||||
// backing type (a collection-expression array here, a List<T> out of the
|
||||
// mapper) is not part of the contract. Element and value types below are
|
||||
// still compared strictly.
|
||||
if (expected is not string && actual is not string)
|
||||
{
|
||||
if (expected is IDictionary expectedMap && actual is IDictionary actualMap)
|
||||
{
|
||||
CompareDictionaries(expectedMap, actualMap, path);
|
||||
return;
|
||||
}
|
||||
|
||||
if (expected is IEnumerable expectedSeq && actual is IEnumerable actualSeq)
|
||||
{
|
||||
CompareSequences(expectedSeq, actualSeq, path);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
var expectedType = expected.GetType();
|
||||
var actualType = actual.GetType();
|
||||
|
||||
if (expectedType != actualType)
|
||||
{
|
||||
Assert.Fail(
|
||||
$"{path}: type changed across the round trip — expected {expectedType.Name}, got {actualType.Name}.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (IsLeaf(expectedType))
|
||||
{
|
||||
Assert.True(
|
||||
Equals(expected, actual),
|
||||
$"{path}: expected '{expected}' but got '{actual}'.");
|
||||
return;
|
||||
}
|
||||
|
||||
CompareProperties(expected, actual, expectedType, path);
|
||||
}
|
||||
|
||||
private static void CompareDictionaries(IDictionary expected, IDictionary actual, string path)
|
||||
{
|
||||
Assert.True(
|
||||
expected.Count == actual.Count,
|
||||
$"{path}: entry count changed — expected {expected.Count}, got {actual.Count}.");
|
||||
|
||||
foreach (DictionaryEntry entry in expected)
|
||||
{
|
||||
Assert.True(
|
||||
actual.Contains(entry.Key),
|
||||
$"{path}: key '{entry.Key}' is missing after the round trip.");
|
||||
Compare(entry.Value, actual[entry.Key], $"{path}['{entry.Key}']");
|
||||
}
|
||||
}
|
||||
|
||||
private static void CompareSequences(IEnumerable expected, IEnumerable actual, string path)
|
||||
{
|
||||
var expectedItems = expected.Cast<object?>().ToList();
|
||||
var actualItems = actual.Cast<object?>().ToList();
|
||||
|
||||
Assert.True(
|
||||
expectedItems.Count == actualItems.Count,
|
||||
$"{path}: element count changed — expected {expectedItems.Count}, got {actualItems.Count}.");
|
||||
|
||||
for (var i = 0; i < expectedItems.Count; i++)
|
||||
{
|
||||
Compare(expectedItems[i], actualItems[i], $"{path}[{i}]");
|
||||
}
|
||||
}
|
||||
|
||||
private static void CompareProperties(object expected, object actual, Type type, string path)
|
||||
{
|
||||
var properties = type
|
||||
.GetProperties(BindingFlags.Public | BindingFlags.Instance)
|
||||
.Where(p => p.CanRead && p.GetIndexParameters().Length == 0)
|
||||
.ToList();
|
||||
|
||||
Assert.True(properties.Count > 0, $"{path}: {type.Name} exposes no readable properties to compare.");
|
||||
|
||||
foreach (var property in properties)
|
||||
{
|
||||
Compare(property.GetValue(expected), property.GetValue(actual), $"{path}.{property.Name}");
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsLeaf(Type type) =>
|
||||
type.IsPrimitive
|
||||
|| type.IsEnum
|
||||
|| type == typeof(string)
|
||||
|| type == typeof(decimal)
|
||||
|| type == typeof(DateTime)
|
||||
|| type == typeof(DateTimeOffset)
|
||||
|| type == typeof(TimeSpan)
|
||||
|| type == typeof(Guid)
|
||||
// Value types with no collection members (e.g. TrackedOperationId) have a
|
||||
// correct structural Equals of their own.
|
||||
|| (type.IsValueType && !type.IsGenericType);
|
||||
|
||||
private static string Describe(object? value) => value is null ? "<null>" : $"{value.GetType().Name}('{value}')";
|
||||
}
|
||||
Reference in New Issue
Block a user