dd4f1c2aa3
AlarmConditionState.Severity mixed two conventions: OPC UA rows carried Part 9 Severity (higher = more severe) while MxGateway native alarms and computed alarms carried a raw AVEVA-style priority (1 = most urgent). Every consumer ranks higher as worse, so Worst Severity, the severity filter/sort and the site event-log Error threshold picked the LEAST urgent MxGateway/computed alarms. - AlarmPriorityBands: the OtOpcUa GalaxyAlarmPriority table (lmxopcua#887, b61f3e05), value for value: 1-250 -> 1000, 251-500 -> 750, 501-750 -> 500, 751-999 -> 250; <= 0 clamps to Critical, > 999 to Low. - MxGatewayAlarmMapper bands the gateway severity (= AVEVA priority); AlarmConditionStateFactory.ForComputed bands PriorityLevel. OPC UA unchanged. - AlarmConditionState gains trailing Priority (int?): the raw source number, exactly the pre-#129 Severity. AlarmStateChanged.Priority and the wire AlarmStateUpdate.priority keep that meaning (no proto change); central derives Severity by kind (SeverityFromPriority), so an older site decodes correctly. - ScriptAlarm gains trailing Priority. The native MES 900-999 band and the MES API Severity/MinSeverity/MaxSeverity key off Priority; rig seed scripts moved from a.Severity to a.Priority, so MES output is unchanged. - AlarmActor raise logs Error when the banded severity >= 700 (same threshold as NativeAlarmActor): priority 1-500 Error, 501+ Warning (was raw >= 700). - NativeAlarmActor upgrades pre-#129 native_alarm_state rows on rehydrate. - Debug snapshot decode restores Condition.Priority from the row's priority. - Docs: Commons, DCL, SiteRuntime, Communication, CentralUI, InboundAPI, HighLevelReqs, MES alarm-status plan, CLAUDE.md. Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
602 lines
28 KiB
C#
602 lines
28 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 }));
|
|
|
|
/// <summary>Verifies the case: round Trips, unchanged.</summary>
|
|
/// <param name="typeName">The typeName test input.</param>
|
|
/// <param name="sampleIndex">The sampleIndex test input.</param>
|
|
[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>
|
|
/// <param name="envelopeName">The name of the request envelope under test.</param>
|
|
[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>
|
|
/// <param name="envelopeName">The name of the reply envelope under test.</param>
|
|
[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
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
|
|
/// <summary>Verifies the case: deployment Status, round Trips.</summary>
|
|
/// <param name="value">The value test input.</param>
|
|
[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)));
|
|
|
|
/// <summary>Verifies the case: browse Node Class, round Trips.</summary>
|
|
/// <param name="value">The value test input.</param>
|
|
[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)));
|
|
|
|
/// <summary>Verifies the case: browse Failure Kind, round Trips.</summary>
|
|
/// <param name="value">The value test input.</param>
|
|
[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)));
|
|
|
|
/// <summary>Verifies the case: read Tag Values Failure Kind, round Trips.</summary>
|
|
/// <param name="value">The value test input.</param>
|
|
[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)));
|
|
|
|
/// <summary>Verifies the case: verify Failure Kind, round Trips.</summary>
|
|
/// <param name="value">The value test input.</param>
|
|
[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)));
|
|
|
|
/// <summary>Verifies the case: store And Forward Category, round Trips.</summary>
|
|
/// <param name="value">The value test input.</param>
|
|
[Theory]
|
|
[InlineData(StoreAndForwardCategory.ExternalSystem)]
|
|
[InlineData(StoreAndForwardCategory.Notification)]
|
|
[InlineData(StoreAndForwardCategory.CachedDbWrite)]
|
|
public void StoreAndForwardCategory_RoundTrips(StoreAndForwardCategory value) =>
|
|
Assert.Equal(value, SiteCommandDtoMapper.FromProto(SiteCommandDtoMapper.ToProto(value)));
|
|
|
|
/// <summary>Verifies the case: alarm State, round Trips.</summary>
|
|
/// <param name="value">The value test input.</param>
|
|
[Theory]
|
|
[InlineData(AlarmState.Active)]
|
|
[InlineData(AlarmState.Normal)]
|
|
public void AlarmState_RoundTrips(AlarmState value) =>
|
|
Assert.Equal(value, SiteCommandDtoMapper.FromProto(SiteCommandDtoMapper.ToProto(value)));
|
|
|
|
/// <summary>Verifies the case: alarm Level, round Trips.</summary>
|
|
/// <param name="value">The value test input.</param>
|
|
[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)));
|
|
|
|
/// <summary>Verifies the case: alarm Kind, round Trips.</summary>
|
|
/// <param name="value">The value test input.</param>
|
|
[Theory]
|
|
[InlineData(AlarmKind.Computed)]
|
|
[InlineData(AlarmKind.NativeOpcUa)]
|
|
[InlineData(AlarmKind.NativeMxAccess)]
|
|
public void AlarmKind_RoundTrips(AlarmKind value) =>
|
|
Assert.Equal(value, SiteCommandDtoMapper.FromProto(SiteCommandDtoMapper.ToProto(value)));
|
|
|
|
/// <summary>Verifies the case: alarm Shelve State, round Trips.</summary>
|
|
/// <param name="value">The value test input.</param>
|
|
[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()
|
|
{
|
|
// Priority equals the row's own Priority, as NativeAlarmActor always emits it (Gitea #129).
|
|
var condition = new AlarmConditionState(true, false, true, AlarmShelveState.PermanentShelved, true, 999, Priority: 500);
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// The debug snapshot's condition DTO carries Severity only; the raw priority (Gitea #129) is
|
|
/// restored from the row's Priority so the MES band keeps reading it on central too.
|
|
/// </summary>
|
|
[Fact]
|
|
public void NativeAlarm_RestoresTheRawPriorityOnItsCondition()
|
|
{
|
|
var original = new AlarmStateChanged("inst", "alarm", AlarmState.Active, 950, DateTimeOffset.UtcNow)
|
|
{
|
|
Kind = AlarmKind.NativeMxAccess,
|
|
Condition = new AlarmConditionState(true, false, null, AlarmShelveState.Unshelved, false, 250, Priority: 950)
|
|
};
|
|
|
|
var restored = SiteCommandDtoMapper.FromProto(SiteCommandDtoMapper.ToProto(original));
|
|
|
|
Assert.Equal(250, restored.Condition.Severity);
|
|
Assert.Equal(950, restored.Condition.Priority);
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
// 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;
|
|
}
|
|
}
|