0f88a953d7
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>
393 lines
15 KiB
C#
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()));
|
|
}
|
|
}
|