Files
mxaccessgw/src/MxGateway.Tests/Contracts/ProtobufContractRoundTripTests.cs
T
Joseph Doherty 0f88a953d7 proto: add alarm-transition event family + ack/query RPCs (PR A.1)
First PR of the alarms-over-gateway epic
(docs/plans/alarms-over-gateway.md in lmxopcua). Pure contract-surface
change — no functional wiring yet. Worker-side subscription (A.2),
gateway-side dispatch + ack handler (A.3), and ConditionRefresh
(A.4) follow.

mxaccess_gateway.proto:

- Extend MxEventFamily with MX_EVENT_FAMILY_ON_ALARM_TRANSITION = 5.
- Extend MxEvent.body oneof with OnAlarmTransitionEvent on_alarm_transition = 24.
- Add OnAlarmTransitionEvent message carrying the full MxAccess alarm
  payload (full reference, source object, alarm-type-name, transition
  kind, raw severity, original raise timestamp, transition timestamp,
  operator user/comment, category, description, current/limit value).
  Mapping to OPC UA 0-1000 severity ladder happens server-side in
  lmxopcua's MxAccessSeverityMapper (B.1) — gateway preserves the
  native MxAccess scale.
- Add AlarmTransitionKind enum (Raise / Acknowledge / Clear / Retrigger).
- Add ActiveAlarmSnapshot + AlarmConditionState for the
  ConditionRefresh stream.
- Add public RPCs AcknowledgeAlarm (unary) and QueryActiveAlarms
  (server-streaming) on MxAccessGateway service.
- Add AcknowledgeAlarmRequest/Reply + QueryActiveAlarmsRequest.

GatewayContractInfo.GatewayProtocolVersion bumps 2 -> 3. Fixture
manifests (proto-inputs, behavior, parity, golden OpenSessionReply)
and protoset descriptor regenerated.

Tests: round-trip serialization for the new messages with
all-fields-populated and empty-optional-fields cases; oneof
last-write-wins guard between OnDataChange and OnAlarmTransition;
descriptor service-method enumeration includes the two new RPCs.
All 273 existing tests still pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 15:34:35 -04:00

393 lines
15 KiB
C#

using Google.Protobuf;
using Google.Protobuf.WellKnownTypes;
using MxGateway.Contracts;
using MxGateway.Contracts.Proto;
namespace MxGateway.Tests.Contracts;
public sealed class ProtobufContractRoundTripTests
{
/// <summary>Verifies that gateway descriptor contains expected public service methods.</summary>
[Fact]
public void GatewayDescriptor_ContainsInitialPublicServiceMethods()
{
var service = Assert.Single(
MxaccessGatewayReflection.Descriptor.Services,
descriptor => descriptor.Name == "MxAccessGateway");
Assert.Contains(service.Methods, method => method.Name == "OpenSession");
Assert.Contains(service.Methods, method => method.Name == "CloseSession");
Assert.Contains(service.Methods, method => method.Name == "Invoke");
Assert.Contains(service.Methods, method => method.Name == "StreamEvents");
Assert.Contains(service.Methods, method => method.Name == "AcknowledgeAlarm");
Assert.Contains(service.Methods, method => method.Name == "QueryActiveAlarms");
}
/// <summary>Verifies that worker envelope descriptor contains required correlation fields.</summary>
[Fact]
public void WorkerEnvelopeDescriptor_ContainsRequiredCorrelationFields()
{
var fields = WorkerEnvelope.Descriptor.Fields.InDeclarationOrder();
Assert.Contains(fields, field => field.Name == "protocol_version");
Assert.Contains(fields, field => field.Name == "session_id");
Assert.Contains(fields, field => field.Name == "sequence");
Assert.Contains(fields, field => field.Name == "correlation_id");
}
/// <summary>Verifies that command request round-trips through serialization.</summary>
[Fact]
public void CommandRequest_RoundTripsMethodSpecificPayload()
{
var original = new MxCommandRequest
{
SessionId = "session-1",
ClientCorrelationId = "client-correlation-1",
Command = new MxCommand
{
Kind = MxCommandKind.Register,
Register = new RegisterCommand
{
ClientName = "mxaccessgw-test-client",
},
},
};
var parsed = MxCommandRequest.Parser.ParseFrom(original.ToByteArray());
Assert.Equal(original, parsed);
Assert.Equal(MxCommand.PayloadOneofCase.Register, parsed.Command.PayloadCase);
}
/// <summary>Verifies that command reply round-trips with return values and statuses.</summary>
[Fact]
public void CommandReply_RoundTripsHResultReturnValueOutParamsAndStatuses()
{
var original = new MxCommandReply
{
SessionId = "session-1",
CorrelationId = "gateway-correlation-1",
Kind = MxCommandKind.AddItem,
ProtocolStatus = new ProtocolStatus
{
Code = ProtocolStatusCode.Ok,
},
Hresult = 0,
ReturnValue = new MxValue
{
DataType = MxDataType.Integer,
Int32Value = 1234,
VariantType = "VT_I4",
},
AddItem = new AddItemReply
{
ItemHandle = 1234,
},
};
original.Statuses.Add(new MxStatusProxy
{
Success = 1,
Category = MxStatusCategory.Ok,
DetectedBy = MxStatusSource.RespondingLmx,
Detail = 0,
});
var parsed = MxCommandReply.Parser.ParseFrom(original.ToByteArray());
Assert.Equal(original, parsed);
Assert.True(parsed.HasHresult);
Assert.Equal(MxCommandReply.PayloadOneofCase.AddItem, parsed.PayloadCase);
Assert.Single(parsed.Statuses);
}
/// <summary>Verifies that event round-trips with value, status, and sequence.</summary>
[Fact]
public void Event_RoundTripsValueStatusSequenceAndBufferedBody()
{
var timestamp = Timestamp.FromDateTime(new DateTime(2026, 4, 26, 20, 0, 0, DateTimeKind.Utc));
var original = new MxEvent
{
Family = MxEventFamily.OnBufferedDataChange,
SessionId = "session-1",
ServerHandle = 10,
ItemHandle = 20,
Value = new MxValue
{
DataType = MxDataType.Float,
ArrayValue = new MxArray
{
ElementDataType = MxDataType.Float,
FloatValues = new FloatArray
{
Values = { 1.5f, 2.5f },
},
Dimensions = { 2 },
VariantType = "VT_ARRAY|VT_R4",
},
},
Quality = 192,
SourceTimestamp = timestamp,
WorkerSequence = 42,
WorkerTimestamp = timestamp,
GatewayReceiveTimestamp = timestamp,
OnBufferedDataChange = new OnBufferedDataChangeEvent
{
DataType = MxDataType.Float,
QualityValues = new MxArray
{
ElementDataType = MxDataType.Integer,
Int32Values = new Int32Array
{
Values = { 192, 192 },
},
Dimensions = { 2 },
},
TimestampValues = new MxArray
{
ElementDataType = MxDataType.Time,
TimestampValues = new TimestampArray
{
Values = { timestamp, timestamp },
},
Dimensions = { 2 },
},
},
};
original.Statuses.Add(new MxStatusProxy
{
Success = 1,
Category = MxStatusCategory.Ok,
DetectedBy = MxStatusSource.RespondingNmx,
Detail = 0,
});
var parsed = MxEvent.Parser.ParseFrom(original.ToByteArray());
Assert.Equal(original, parsed);
Assert.Equal(MxEvent.BodyOneofCase.OnBufferedDataChange, parsed.BodyCase);
Assert.Single(parsed.Statuses);
}
/// <summary>Verifies that worker envelope round-trips through serialization preserving protocol and command fields.</summary>
[Fact]
public void WorkerEnvelope_RoundTripsProtocolFieldsAndCommandBody()
{
var original = new WorkerEnvelope
{
ProtocolVersion = GatewayContractInfo.WorkerProtocolVersion,
SessionId = "session-1",
Sequence = 7,
CorrelationId = "gateway-correlation-1",
WorkerCommand = new WorkerCommand
{
EnqueueTimestamp = Timestamp.FromDateTime(
new DateTime(2026, 4, 26, 20, 5, 0, DateTimeKind.Utc)),
Command = new MxCommand
{
Kind = MxCommandKind.Advise,
Advise = new AdviseCommand
{
ServerHandle = 10,
ItemHandle = 20,
},
},
},
};
var parsed = WorkerEnvelope.Parser.ParseFrom(original.ToByteArray());
Assert.Equal(original, parsed);
Assert.Equal(WorkerEnvelope.BodyOneofCase.WorkerCommand, parsed.BodyCase);
Assert.Equal(MxCommand.PayloadOneofCase.Advise, parsed.WorkerCommand.Command.PayloadCase);
}
/// <summary>Verifies that an OnAlarmTransition event round-trips with full payload.</summary>
[Fact]
public void Event_RoundTripsOnAlarmTransitionWithFullPayload()
{
var raise = Timestamp.FromDateTime(new DateTime(2026, 5, 1, 12, 0, 0, DateTimeKind.Utc));
var ack = Timestamp.FromDateTime(new DateTime(2026, 5, 1, 12, 0, 30, DateTimeKind.Utc));
var original = new MxEvent
{
Family = MxEventFamily.OnAlarmTransition,
SessionId = "session-1",
WorkerSequence = 99,
WorkerTimestamp = ack,
GatewayReceiveTimestamp = ack,
OnAlarmTransition = new OnAlarmTransitionEvent
{
AlarmFullReference = "Tank01.Level.HiHi",
SourceObjectReference = "Tank01",
AlarmTypeName = "AnalogLimitAlarm.HiHi",
TransitionKind = AlarmTransitionKind.Acknowledge,
Severity = 750,
OriginalRaiseTimestamp = raise,
TransitionTimestamp = ack,
OperatorUser = "operator1",
OperatorComment = "investigating",
Category = "Process",
Description = "Tank 01 high-high level",
CurrentValue = new MxValue
{
DataType = MxDataType.Float,
FloatValue = 95.4f,
VariantType = "VT_R4",
},
LimitValue = new MxValue
{
DataType = MxDataType.Float,
FloatValue = 90.0f,
VariantType = "VT_R4",
},
},
};
var parsed = MxEvent.Parser.ParseFrom(original.ToByteArray());
Assert.Equal(original, parsed);
Assert.Equal(MxEvent.BodyOneofCase.OnAlarmTransition, parsed.BodyCase);
Assert.Equal(AlarmTransitionKind.Acknowledge, parsed.OnAlarmTransition.TransitionKind);
Assert.Equal(raise, parsed.OnAlarmTransition.OriginalRaiseTimestamp);
Assert.Equal("operator1", parsed.OnAlarmTransition.OperatorUser);
}
/// <summary>Verifies that an OnAlarmTransition event round-trips with only the required fields populated.</summary>
[Fact]
public void Event_RoundTripsOnAlarmTransitionWithOptionalFieldsEmpty()
{
var raise = Timestamp.FromDateTime(new DateTime(2026, 5, 1, 12, 0, 0, DateTimeKind.Utc));
var original = new MxEvent
{
Family = MxEventFamily.OnAlarmTransition,
SessionId = "session-1",
WorkerSequence = 100,
OnAlarmTransition = new OnAlarmTransitionEvent
{
AlarmFullReference = "Tank01.Level.HiHi",
AlarmTypeName = "AnalogLimitAlarm.HiHi",
TransitionKind = AlarmTransitionKind.Raise,
Severity = 750,
TransitionTimestamp = raise,
},
};
var parsed = MxEvent.Parser.ParseFrom(original.ToByteArray());
Assert.Equal(original, parsed);
Assert.Equal(string.Empty, parsed.OnAlarmTransition.OperatorUser);
Assert.Equal(string.Empty, parsed.OnAlarmTransition.OperatorComment);
Assert.Null(parsed.OnAlarmTransition.OriginalRaiseTimestamp);
Assert.Null(parsed.OnAlarmTransition.CurrentValue);
}
/// <summary>Verifies that an MxEvent body oneof rejects multiple bodies — last write wins per proto3 semantics.</summary>
[Fact]
public void Event_OneofGuard_LastBodyWins()
{
var ev = new MxEvent
{
Family = MxEventFamily.OnAlarmTransition,
OnDataChange = new OnDataChangeEvent(),
OnAlarmTransition = new OnAlarmTransitionEvent
{
AlarmFullReference = "X",
TransitionKind = AlarmTransitionKind.Raise,
},
};
Assert.Equal(MxEvent.BodyOneofCase.OnAlarmTransition, ev.BodyCase);
Assert.Null(ev.OnDataChange);
}
/// <summary>Verifies that AcknowledgeAlarmRequest round-trips through serialization.</summary>
[Fact]
public void AcknowledgeAlarmRequest_RoundTripsAllFields()
{
var original = new AcknowledgeAlarmRequest
{
SessionId = "session-1",
ClientCorrelationId = "client-correlation-7",
AlarmFullReference = "Tank01.Level.HiHi",
Comment = "shift handover",
OperatorUser = "operator2",
};
var parsed = AcknowledgeAlarmRequest.Parser.ParseFrom(original.ToByteArray());
Assert.Equal(original, parsed);
}
/// <summary>Verifies that AcknowledgeAlarmReply round-trips with status, hresult, and diagnostics.</summary>
[Fact]
public void AcknowledgeAlarmReply_RoundTripsStatusAndHresult()
{
var original = new AcknowledgeAlarmReply
{
SessionId = "session-1",
CorrelationId = "gateway-correlation-7",
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
Hresult = 0,
Status = new MxStatusProxy
{
Success = 1,
Category = MxStatusCategory.Ok,
DetectedBy = MxStatusSource.RespondingLmx,
},
DiagnosticMessage = "ack accepted",
};
var parsed = AcknowledgeAlarmReply.Parser.ParseFrom(original.ToByteArray());
Assert.Equal(original, parsed);
Assert.True(parsed.HasHresult);
}
/// <summary>Verifies that ActiveAlarmSnapshot round-trips with current state and operator metadata.</summary>
[Fact]
public void ActiveAlarmSnapshot_RoundTripsAllFields()
{
var raise = Timestamp.FromDateTime(new DateTime(2026, 5, 1, 12, 0, 0, DateTimeKind.Utc));
var ack = Timestamp.FromDateTime(new DateTime(2026, 5, 1, 12, 0, 30, DateTimeKind.Utc));
var original = new ActiveAlarmSnapshot
{
AlarmFullReference = "Tank01.Level.HiHi",
SourceObjectReference = "Tank01",
AlarmTypeName = "AnalogLimitAlarm.HiHi",
Severity = 750,
OriginalRaiseTimestamp = raise,
CurrentState = AlarmConditionState.ActiveAcked,
Category = "Process",
Description = "Tank 01 high-high level",
LastTransitionTimestamp = ack,
OperatorUser = "operator2",
OperatorComment = "investigating",
};
var parsed = ActiveAlarmSnapshot.Parser.ParseFrom(original.ToByteArray());
Assert.Equal(original, parsed);
Assert.Equal(AlarmConditionState.ActiveAcked, parsed.CurrentState);
}
/// <summary>Verifies that QueryActiveAlarmsRequest round-trips empty filter prefix.</summary>
[Fact]
public void QueryActiveAlarmsRequest_RoundTripsWithAndWithoutFilter()
{
var withoutFilter = new QueryActiveAlarmsRequest
{
SessionId = "session-1",
ClientCorrelationId = "client-correlation-8",
};
var withFilter = new QueryActiveAlarmsRequest
{
SessionId = "session-1",
ClientCorrelationId = "client-correlation-9",
AlarmFilterPrefix = "Tank01.",
};
Assert.Equal(withoutFilter, QueryActiveAlarmsRequest.Parser.ParseFrom(withoutFilter.ToByteArray()));
Assert.Equal(withFilter, QueryActiveAlarmsRequest.Parser.ParseFrom(withFilter.ToByteArray()));
}
}