1547 lines
59 KiB
C#
1547 lines
59 KiB
C#
using Google.Protobuf;
|
|
using Google.Protobuf.WellKnownTypes;
|
|
using ZB.MOM.WW.MxGateway.Contracts;
|
|
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
|
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
|
|
|
|
namespace ZB.MOM.WW.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 == "StreamAlarms");
|
|
}
|
|
|
|
/// <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
|
|
{
|
|
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
|
|
{
|
|
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>
|
|
/// Pins the documented command/reply payload-reuse contract: an
|
|
/// <c>ACKNOWLEDGE_ALARM_BY_NAME</c> command's reply intentionally has no
|
|
/// by-name-specific payload case and instead reuses the
|
|
/// <c>acknowledge_alarm</c> (<see cref="AcknowledgeAlarmReplyPayload"/>)
|
|
/// case. A future change that adds a separate by-name reply case — or
|
|
/// drops the reuse — breaks this test. See Contracts-002 and
|
|
/// docs/AlarmClientDiscovery.md section 4.
|
|
/// </summary>
|
|
[Fact]
|
|
public void MxCommandReply_AcknowledgeAlarmByName_ReusesAcknowledgeAlarmPayloadCase()
|
|
{
|
|
// The reply oneof must NOT have a by-name-specific case. If a future
|
|
// edit adds one, this assertion fails and forces the doc/test contract
|
|
// to be revisited deliberately.
|
|
foreach (MxCommandReply.PayloadOneofCase value in
|
|
System.Enum.GetValues<MxCommandReply.PayloadOneofCase>())
|
|
{
|
|
Assert.NotEqual("AcknowledgeAlarmByName", value.ToString());
|
|
}
|
|
|
|
var original = new MxCommandReply
|
|
{
|
|
SessionId = "session-1",
|
|
CorrelationId = "gateway-correlation-7",
|
|
Kind = MxCommandKind.AcknowledgeAlarmByName,
|
|
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
|
Hresult = 0,
|
|
// By-name ack reuses the acknowledge_alarm payload case; see the
|
|
// worker's MxAccessCommandExecutor.ExecuteAcknowledgeAlarmByName.
|
|
AcknowledgeAlarm = new AcknowledgeAlarmReplyPayload
|
|
{
|
|
NativeStatus = 0,
|
|
},
|
|
};
|
|
|
|
var parsed = MxCommandReply.Parser.ParseFrom(original.ToByteArray());
|
|
|
|
Assert.Equal(original, parsed);
|
|
// Kind distinguishes the by-name ack; the payload case is shared.
|
|
Assert.Equal(MxCommandKind.AcknowledgeAlarmByName, parsed.Kind);
|
|
Assert.Equal(MxCommandReply.PayloadOneofCase.AcknowledgeAlarm, parsed.PayloadCase);
|
|
Assert.Equal(0, parsed.AcknowledgeAlarm.NativeStatus);
|
|
// The by-name command has its own command payload case — the asymmetry
|
|
// with the reply oneof is the documented contract under test.
|
|
Assert.Contains(
|
|
MxCommand.PayloadOneofCase.AcknowledgeAlarmByNameCommand,
|
|
System.Enum.GetValues<MxCommand.PayloadOneofCase>());
|
|
}
|
|
|
|
/// <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 StreamAlarmsRequest round-trips with and without a filter prefix.</summary>
|
|
[Fact]
|
|
public void StreamAlarmsRequest_RoundTripsWithAndWithoutFilter()
|
|
{
|
|
var withoutFilter = new StreamAlarmsRequest
|
|
{
|
|
ClientCorrelationId = "client-correlation-8",
|
|
};
|
|
|
|
var withFilter = new StreamAlarmsRequest
|
|
{
|
|
ClientCorrelationId = "client-correlation-9",
|
|
AlarmFilterPrefix = "Tank01.",
|
|
};
|
|
|
|
Assert.Equal(withoutFilter, StreamAlarmsRequest.Parser.ParseFrom(withoutFilter.ToByteArray()));
|
|
Assert.Equal(withFilter, StreamAlarmsRequest.Parser.ParseFrom(withFilter.ToByteArray()));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that <c>QueryActiveAlarmsRequest</c> pins the additive-only field numbering
|
|
/// (<c>session_id = 1</c>, <c>client_correlation_id = 2</c>, <c>alarm_filter_prefix = 3</c>)
|
|
/// advertised in its proto comment, that the message round-trips with the optional
|
|
/// <c>alarm_filter_prefix</c> populated (the filter semantic the public RPC comment
|
|
/// documents), and that <c>QueryActiveAlarms</c> remains on the public service surface.
|
|
/// </summary>
|
|
[Fact]
|
|
public void QueryActiveAlarmsRequest_PinsFieldNumbersAndRoundTripsPrefixFilter()
|
|
{
|
|
var service = Assert.Single(
|
|
MxaccessGatewayReflection.Descriptor.Services,
|
|
descriptor => descriptor.Name == "MxAccessGateway");
|
|
Assert.Contains(service.Methods, method => method.Name == "QueryActiveAlarms");
|
|
|
|
var fields = QueryActiveAlarmsRequest.Descriptor.Fields;
|
|
Assert.Equal(1, fields[QueryActiveAlarmsRequest.SessionIdFieldNumber].FieldNumber);
|
|
Assert.Equal(2, fields[QueryActiveAlarmsRequest.ClientCorrelationIdFieldNumber].FieldNumber);
|
|
Assert.Equal(3, fields[QueryActiveAlarmsRequest.AlarmFilterPrefixFieldNumber].FieldNumber);
|
|
Assert.Equal("session_id", fields[QueryActiveAlarmsRequest.SessionIdFieldNumber].Name);
|
|
Assert.Equal("client_correlation_id", fields[QueryActiveAlarmsRequest.ClientCorrelationIdFieldNumber].Name);
|
|
Assert.Equal("alarm_filter_prefix", fields[QueryActiveAlarmsRequest.AlarmFilterPrefixFieldNumber].Name);
|
|
|
|
var withoutFilter = new QueryActiveAlarmsRequest
|
|
{
|
|
ClientCorrelationId = "client-correlation-10",
|
|
};
|
|
|
|
var withFilter = new QueryActiveAlarmsRequest
|
|
{
|
|
ClientCorrelationId = "client-correlation-11",
|
|
AlarmFilterPrefix = "Tank01.",
|
|
};
|
|
|
|
Assert.Equal(withoutFilter, QueryActiveAlarmsRequest.Parser.ParseFrom(withoutFilter.ToByteArray()));
|
|
var parsedWithFilter = QueryActiveAlarmsRequest.Parser.ParseFrom(withFilter.ToByteArray());
|
|
Assert.Equal(withFilter, parsedWithFilter);
|
|
Assert.Equal("Tank01.", parsedWithFilter.AlarmFilterPrefix);
|
|
}
|
|
|
|
/// <summary>Verifies that an MxValue carrying a raw_value bytes payload round-trips.</summary>
|
|
[Fact]
|
|
public void MxValue_RoundTripsRawValueBytesPayload()
|
|
{
|
|
var original = new MxValue
|
|
{
|
|
DataType = MxDataType.Unknown,
|
|
VariantType = "VT_UNKNOWN",
|
|
RawDataType = 99,
|
|
RawDiagnostic = "uninterpreted COM variant",
|
|
RawValue = ByteString.CopyFrom(0x01, 0x02, 0xFE, 0xFF),
|
|
};
|
|
|
|
var parsed = MxValue.Parser.ParseFrom(original.ToByteArray());
|
|
|
|
Assert.Equal(original, parsed);
|
|
Assert.Equal(MxValue.KindOneofCase.RawValue, parsed.KindCase);
|
|
Assert.Equal(new byte[] { 0x01, 0x02, 0xFE, 0xFF }, parsed.RawValue.ToByteArray());
|
|
}
|
|
|
|
/// <summary>Verifies that an MxArray carrying a RawArray of byte blobs round-trips.</summary>
|
|
[Fact]
|
|
public void MxArray_RoundTripsRawArrayPayload()
|
|
{
|
|
var original = new MxArray
|
|
{
|
|
ElementDataType = MxDataType.Unknown,
|
|
VariantType = "VT_ARRAY|VT_UNKNOWN",
|
|
RawElementDataType = 99,
|
|
RawDiagnostic = "uninterpreted SAFEARRAY",
|
|
Dimensions = { 2 },
|
|
RawValues = new RawArray
|
|
{
|
|
Values =
|
|
{
|
|
ByteString.CopyFrom(0xAA, 0xBB),
|
|
ByteString.CopyFrom(0xCC, 0xDD),
|
|
},
|
|
},
|
|
};
|
|
|
|
var parsed = MxArray.Parser.ParseFrom(original.ToByteArray());
|
|
|
|
Assert.Equal(original, parsed);
|
|
Assert.Equal(MxArray.ValuesOneofCase.RawValues, parsed.ValuesCase);
|
|
Assert.Equal(2, parsed.RawValues.Values.Count);
|
|
}
|
|
|
|
/// <summary>Verifies that a BulkSubscribeReply with per-item SubscribeResults round-trips.</summary>
|
|
[Fact]
|
|
public void BulkSubscribeReply_RoundTripsSubscribeResults()
|
|
{
|
|
var original = new BulkSubscribeReply
|
|
{
|
|
Results =
|
|
{
|
|
new SubscribeResult
|
|
{
|
|
ServerHandle = 10,
|
|
TagAddress = "Provider!Tank01.Level",
|
|
ItemHandle = 21,
|
|
WasSuccessful = true,
|
|
},
|
|
new SubscribeResult
|
|
{
|
|
ServerHandle = 10,
|
|
TagAddress = "Provider!Bad.Tag",
|
|
WasSuccessful = false,
|
|
ErrorMessage = "item not found",
|
|
},
|
|
},
|
|
};
|
|
|
|
var parsed = BulkSubscribeReply.Parser.ParseFrom(original.ToByteArray());
|
|
|
|
Assert.Equal(original, parsed);
|
|
Assert.Equal(2, parsed.Results.Count);
|
|
Assert.True(parsed.Results[0].WasSuccessful);
|
|
Assert.False(parsed.Results[1].WasSuccessful);
|
|
}
|
|
|
|
/// <summary>Verifies that a bulk-subscribe command and its BulkSubscribeReply payload round-trip.</summary>
|
|
[Fact]
|
|
public void MxCommandReply_RoundTripsBulkSubscribePayload()
|
|
{
|
|
var original = new MxCommandReply
|
|
{
|
|
SessionId = "session-1",
|
|
CorrelationId = "gateway-correlation-bulk",
|
|
Kind = MxCommandKind.SubscribeBulk,
|
|
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
|
Hresult = 0,
|
|
SubscribeBulk = new BulkSubscribeReply
|
|
{
|
|
Results =
|
|
{
|
|
new SubscribeResult
|
|
{
|
|
ServerHandle = 5,
|
|
TagAddress = "Provider!Tank01.Level",
|
|
ItemHandle = 7,
|
|
WasSuccessful = true,
|
|
},
|
|
},
|
|
},
|
|
};
|
|
|
|
var parsed = MxCommandReply.Parser.ParseFrom(original.ToByteArray());
|
|
|
|
Assert.Equal(original, parsed);
|
|
Assert.Equal(MxCommandReply.PayloadOneofCase.SubscribeBulk, parsed.PayloadCase);
|
|
Assert.Single(parsed.SubscribeBulk.Results);
|
|
}
|
|
|
|
/// <summary>Verifies that a WorkerEnvelope carrying a WorkerFault body round-trips.</summary>
|
|
[Fact]
|
|
public void WorkerEnvelope_RoundTripsWorkerFaultBody()
|
|
{
|
|
var original = new WorkerEnvelope
|
|
{
|
|
ProtocolVersion = GatewayContractInfo.WorkerProtocolVersion,
|
|
SessionId = "session-1",
|
|
Sequence = 11,
|
|
CorrelationId = "gateway-correlation-fault",
|
|
WorkerFault = new WorkerFault
|
|
{
|
|
Category = WorkerFaultCategory.MxaccessCommandFailed,
|
|
CommandMethod = "Register",
|
|
Hresult = unchecked((int)0x80004005),
|
|
ExceptionType = "System.Runtime.InteropServices.COMException",
|
|
DiagnosticMessage = "MXAccess COM call failed.",
|
|
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.ProtocolViolation },
|
|
},
|
|
};
|
|
|
|
var parsed = WorkerEnvelope.Parser.ParseFrom(original.ToByteArray());
|
|
|
|
Assert.Equal(original, parsed);
|
|
Assert.Equal(WorkerEnvelope.BodyOneofCase.WorkerFault, parsed.BodyCase);
|
|
Assert.True(parsed.WorkerFault.HasHresult);
|
|
}
|
|
|
|
/// <summary>Verifies that a WorkerEnvelope carrying a WorkerHeartbeat body round-trips.</summary>
|
|
[Fact]
|
|
public void WorkerEnvelope_RoundTripsWorkerHeartbeatBody()
|
|
{
|
|
var activity = Timestamp.FromDateTime(new DateTime(2026, 5, 18, 9, 0, 0, DateTimeKind.Utc));
|
|
var original = new WorkerEnvelope
|
|
{
|
|
ProtocolVersion = GatewayContractInfo.WorkerProtocolVersion,
|
|
SessionId = "session-1",
|
|
Sequence = 12,
|
|
CorrelationId = "gateway-correlation-heartbeat",
|
|
WorkerHeartbeat = new WorkerHeartbeat
|
|
{
|
|
WorkerProcessId = 4242,
|
|
State = WorkerState.Ready,
|
|
LastStaActivityTimestamp = activity,
|
|
PendingCommandCount = 3,
|
|
OutboundEventQueueDepth = 7,
|
|
LastEventSequence = 1234,
|
|
CurrentCommandCorrelationId = "in-flight-1",
|
|
},
|
|
};
|
|
|
|
var parsed = WorkerEnvelope.Parser.ParseFrom(original.ToByteArray());
|
|
|
|
Assert.Equal(original, parsed);
|
|
Assert.Equal(WorkerEnvelope.BodyOneofCase.WorkerHeartbeat, parsed.BodyCase);
|
|
Assert.Equal(WorkerState.Ready, parsed.WorkerHeartbeat.State);
|
|
}
|
|
|
|
/// <summary>Verifies that the Galaxy Repository service descriptor exposes its browse RPCs.</summary>
|
|
[Fact]
|
|
public void GalaxyRepositoryDescriptor_ContainsBrowseServiceMethods()
|
|
{
|
|
var service = Assert.Single(
|
|
GalaxyRepositoryReflection.Descriptor.Services,
|
|
descriptor => descriptor.Name == "GalaxyRepository");
|
|
|
|
Assert.Contains(service.Methods, method => method.Name == "TestConnection");
|
|
Assert.Contains(service.Methods, method => method.Name == "GetLastDeployTime");
|
|
Assert.Contains(service.Methods, method => method.Name == "DiscoverHierarchy");
|
|
Assert.Contains(service.Methods, method => method.Name == "WatchDeployEvents");
|
|
Assert.Contains(service.Methods, method => method.Name == "BrowseChildren");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that a DiscoverHierarchyRequest round-trips through every
|
|
/// <c>root</c> oneof arm and its proto wrapper-typed <c>max_depth</c> field.
|
|
/// </summary>
|
|
/// <param name="rootArm">The oneof arm selector (0=RootGobjectId, 1=RootTagName, 2=RootContainedPath).</param>
|
|
[Theory]
|
|
[InlineData(0)]
|
|
[InlineData(1)]
|
|
[InlineData(2)]
|
|
public void DiscoverHierarchyRequest_RoundTripsRootOneofAndWrapperFields(int rootArm)
|
|
{
|
|
var original = new DiscoverHierarchyRequest
|
|
{
|
|
PageSize = 100,
|
|
PageToken = "page-2",
|
|
MaxDepth = 5,
|
|
CategoryIds = { 3, 9 },
|
|
TemplateChainContains = { "Analog", "Pump" },
|
|
TagNameGlob = "Tank*",
|
|
IncludeAttributes = true,
|
|
AlarmBearingOnly = true,
|
|
HistorizedOnly = false,
|
|
};
|
|
switch (rootArm)
|
|
{
|
|
case 0:
|
|
original.RootGobjectId = 4711;
|
|
break;
|
|
case 1:
|
|
original.RootTagName = "Tank01";
|
|
break;
|
|
default:
|
|
original.RootContainedPath = "Area1.Tank01";
|
|
break;
|
|
}
|
|
|
|
var parsed = DiscoverHierarchyRequest.Parser.ParseFrom(original.ToByteArray());
|
|
|
|
Assert.Equal(original, parsed);
|
|
Assert.Equal(original.RootCase, parsed.RootCase);
|
|
Assert.NotEqual(DiscoverHierarchyRequest.RootOneofCase.None, parsed.RootCase);
|
|
Assert.NotNull(parsed.MaxDepth);
|
|
Assert.Equal(5, parsed.MaxDepth!.Value);
|
|
Assert.True(parsed.HasIncludeAttributes);
|
|
Assert.True(parsed.IncludeAttributes);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that a DiscoverHierarchyReply round-trips with nested
|
|
/// GalaxyObject and GalaxyAttribute graphs.
|
|
/// </summary>
|
|
[Fact]
|
|
public void DiscoverHierarchyReply_RoundTripsObjectAndAttributeGraph()
|
|
{
|
|
var original = new DiscoverHierarchyReply
|
|
{
|
|
NextPageToken = "page-3",
|
|
TotalObjectCount = 2,
|
|
Objects =
|
|
{
|
|
new GalaxyObject
|
|
{
|
|
GobjectId = 4711,
|
|
TagName = "Tank01",
|
|
ContainedName = "Tank01",
|
|
BrowseName = "Tank 01",
|
|
ParentGobjectId = 12,
|
|
IsArea = false,
|
|
CategoryId = 3,
|
|
HostedByGobjectId = 8,
|
|
TemplateChain = { "$AnalogDevice", "$Tank" },
|
|
Attributes =
|
|
{
|
|
new GalaxyAttribute
|
|
{
|
|
AttributeName = "Level",
|
|
FullTagReference = "Galaxy!Tank01.Level",
|
|
MxDataType = 3,
|
|
DataTypeName = "Float",
|
|
IsArray = false,
|
|
ArrayDimension = 0,
|
|
ArrayDimensionPresent = false,
|
|
MxAttributeCategory = 1,
|
|
SecurityClassification = 0,
|
|
IsHistorized = true,
|
|
IsAlarm = true,
|
|
},
|
|
},
|
|
},
|
|
new GalaxyObject
|
|
{
|
|
GobjectId = 12,
|
|
TagName = "Area1",
|
|
IsArea = true,
|
|
},
|
|
},
|
|
};
|
|
|
|
var parsed = DiscoverHierarchyReply.Parser.ParseFrom(original.ToByteArray());
|
|
|
|
Assert.Equal(original, parsed);
|
|
Assert.Equal(2, parsed.Objects.Count);
|
|
Assert.Single(parsed.Objects[0].Attributes);
|
|
Assert.True(parsed.Objects[0].Attributes[0].IsAlarm);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that a BrowseChildrenRequest round-trips through every
|
|
/// <c>parent</c> oneof arm with the full filter set populated.
|
|
/// </summary>
|
|
/// <param name="parentArm">The oneof arm selector (0=ParentGobjectId, 1=ParentTagName, 2=ParentContainedPath).</param>
|
|
[Theory]
|
|
[InlineData(0)]
|
|
[InlineData(1)]
|
|
[InlineData(2)]
|
|
public void BrowseChildrenRequest_RoundTripsParentOneofAndFilters(int parentArm)
|
|
{
|
|
var original = new BrowseChildrenRequest
|
|
{
|
|
PageSize = 200,
|
|
PageToken = "opaque-2",
|
|
CategoryIds = { 3, 9 },
|
|
TemplateChainContains = { "Analog", "Pump" },
|
|
TagNameGlob = "Tank*",
|
|
IncludeAttributes = true,
|
|
AlarmBearingOnly = true,
|
|
HistorizedOnly = false,
|
|
};
|
|
switch (parentArm)
|
|
{
|
|
case 0:
|
|
original.ParentGobjectId = 4711;
|
|
break;
|
|
case 1:
|
|
original.ParentTagName = "Tank01";
|
|
break;
|
|
default:
|
|
original.ParentContainedPath = "Area1.Tank01";
|
|
break;
|
|
}
|
|
|
|
var parsed = BrowseChildrenRequest.Parser.ParseFrom(original.ToByteArray());
|
|
|
|
Assert.Equal(original, parsed);
|
|
Assert.Equal(original.ParentCase, parsed.ParentCase);
|
|
Assert.NotEqual(BrowseChildrenRequest.ParentOneofCase.None, parsed.ParentCase);
|
|
Assert.True(parsed.HasIncludeAttributes);
|
|
Assert.True(parsed.IncludeAttributes);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that a BrowseChildrenReply round-trips its children list,
|
|
/// the parallel-indexed <c>child_has_children</c> array, and the
|
|
/// cache sequence used to bind page tokens.
|
|
/// </summary>
|
|
[Fact]
|
|
public void BrowseChildrenReply_RoundTripsChildrenAndHasChildrenParallelArrays()
|
|
{
|
|
var original = new BrowseChildrenReply
|
|
{
|
|
NextPageToken = "opaque-3",
|
|
TotalChildCount = 2,
|
|
CacheSequence = 42UL,
|
|
Children =
|
|
{
|
|
new GalaxyObject
|
|
{
|
|
GobjectId = 4711,
|
|
TagName = "Tank01",
|
|
ContainedName = "Tank01",
|
|
BrowseName = "Tank 01",
|
|
ParentGobjectId = 12,
|
|
IsArea = false,
|
|
CategoryId = 3,
|
|
HostedByGobjectId = 8,
|
|
TemplateChain = { "$AnalogDevice", "$Tank" },
|
|
Attributes =
|
|
{
|
|
new GalaxyAttribute
|
|
{
|
|
AttributeName = "Level",
|
|
FullTagReference = "Galaxy!Tank01.Level",
|
|
MxDataType = 3,
|
|
DataTypeName = "Float",
|
|
IsArray = false,
|
|
ArrayDimension = 0,
|
|
ArrayDimensionPresent = false,
|
|
MxAttributeCategory = 1,
|
|
SecurityClassification = 0,
|
|
IsHistorized = true,
|
|
IsAlarm = true,
|
|
},
|
|
},
|
|
},
|
|
new GalaxyObject
|
|
{
|
|
GobjectId = 12,
|
|
TagName = "Area1",
|
|
IsArea = true,
|
|
},
|
|
},
|
|
ChildHasChildren = { true, false },
|
|
};
|
|
|
|
var parsed = BrowseChildrenReply.Parser.ParseFrom(original.ToByteArray());
|
|
|
|
Assert.Equal(original, parsed);
|
|
Assert.Equal(2, parsed.Children.Count);
|
|
Assert.Equal(2, parsed.ChildHasChildren.Count);
|
|
Assert.True(parsed.ChildHasChildren[0]);
|
|
Assert.False(parsed.ChildHasChildren[1]);
|
|
Assert.Equal(42UL, parsed.CacheSequence);
|
|
}
|
|
|
|
/// <summary>Verifies that a DeployEvent round-trips with its timestamp and counters.</summary>
|
|
[Fact]
|
|
public void DeployEvent_RoundTripsTimestampAndCounters()
|
|
{
|
|
var observed = Timestamp.FromDateTime(new DateTime(2026, 5, 18, 8, 30, 0, DateTimeKind.Utc));
|
|
var deploy = Timestamp.FromDateTime(new DateTime(2026, 5, 18, 8, 0, 0, DateTimeKind.Utc));
|
|
var original = new DeployEvent
|
|
{
|
|
Sequence = 17,
|
|
ObservedAt = observed,
|
|
TimeOfLastDeploy = deploy,
|
|
TimeOfLastDeployPresent = true,
|
|
ObjectCount = 240,
|
|
AttributeCount = 3600,
|
|
};
|
|
|
|
var parsed = DeployEvent.Parser.ParseFrom(original.ToByteArray());
|
|
|
|
Assert.Equal(original, parsed);
|
|
Assert.True(parsed.TimeOfLastDeployPresent);
|
|
Assert.Equal(deploy, parsed.TimeOfLastDeploy);
|
|
}
|
|
|
|
/// <summary>Verifies that GetLastDeployTimeReply and TestConnectionReply round-trip.</summary>
|
|
[Fact]
|
|
public void GalaxyConnectionReplies_RoundTrip()
|
|
{
|
|
var deploy = Timestamp.FromDateTime(new DateTime(2026, 5, 18, 8, 0, 0, DateTimeKind.Utc));
|
|
var lastDeploy = new GetLastDeployTimeReply
|
|
{
|
|
Present = true,
|
|
TimeOfLastDeploy = deploy,
|
|
};
|
|
var testConnection = new TestConnectionReply { Ok = true };
|
|
|
|
Assert.Equal(lastDeploy, GetLastDeployTimeReply.Parser.ParseFrom(lastDeploy.ToByteArray()));
|
|
Assert.Equal(testConnection, TestConnectionReply.Parser.ParseFrom(testConnection.ToByteArray()));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that a <see cref="WriteBulkCommand"/> carrying multiple
|
|
/// <see cref="WriteBulkEntry"/> items round-trips, including the
|
|
/// per-entry <c>value</c> and <c>user_id</c> fields.
|
|
/// </summary>
|
|
[Fact]
|
|
public void WriteBulkCommand_RoundTripsEntries()
|
|
{
|
|
var original = new MxCommand
|
|
{
|
|
Kind = MxCommandKind.WriteBulk,
|
|
WriteBulk = new WriteBulkCommand
|
|
{
|
|
ServerHandle = 10,
|
|
Entries =
|
|
{
|
|
new WriteBulkEntry
|
|
{
|
|
ItemHandle = 21,
|
|
UserId = 7,
|
|
Value = new MxValue
|
|
{
|
|
DataType = MxDataType.Float,
|
|
FloatValue = 1.25f,
|
|
VariantType = "VT_R4",
|
|
},
|
|
},
|
|
new WriteBulkEntry
|
|
{
|
|
ItemHandle = 22,
|
|
Value = new MxValue
|
|
{
|
|
DataType = MxDataType.Integer,
|
|
Int32Value = 42,
|
|
VariantType = "VT_I4",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
};
|
|
|
|
var parsed = MxCommand.Parser.ParseFrom(original.ToByteArray());
|
|
|
|
Assert.Equal(original, parsed);
|
|
Assert.Equal(MxCommand.PayloadOneofCase.WriteBulk, parsed.PayloadCase);
|
|
Assert.Equal(2, parsed.WriteBulk.Entries.Count);
|
|
Assert.Equal(7, parsed.WriteBulk.Entries[0].UserId);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that a <see cref="Write2BulkCommand"/> round-trips, including
|
|
/// the per-entry <c>timestamp_value</c> field that distinguishes Write2
|
|
/// from Write.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Write2BulkCommand_RoundTripsEntriesWithTimestampValue()
|
|
{
|
|
var timestamp = Timestamp.FromDateTime(new DateTime(2026, 5, 19, 11, 0, 0, DateTimeKind.Utc));
|
|
var original = new MxCommand
|
|
{
|
|
Kind = MxCommandKind.Write2Bulk,
|
|
Write2Bulk = new Write2BulkCommand
|
|
{
|
|
ServerHandle = 10,
|
|
Entries =
|
|
{
|
|
new Write2BulkEntry
|
|
{
|
|
ItemHandle = 21,
|
|
UserId = 7,
|
|
Value = new MxValue
|
|
{
|
|
DataType = MxDataType.Float,
|
|
FloatValue = 99.9f,
|
|
VariantType = "VT_R4",
|
|
},
|
|
TimestampValue = new MxValue
|
|
{
|
|
DataType = MxDataType.Time,
|
|
TimestampValue = timestamp,
|
|
VariantType = "VT_DATE",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
};
|
|
|
|
var parsed = MxCommand.Parser.ParseFrom(original.ToByteArray());
|
|
|
|
Assert.Equal(original, parsed);
|
|
Assert.Equal(MxCommand.PayloadOneofCase.Write2Bulk, parsed.PayloadCase);
|
|
Assert.NotNull(parsed.Write2Bulk.Entries[0].TimestampValue);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that a <see cref="WriteSecuredBulkCommand"/> round-trips,
|
|
/// pinning the credential-bearing entry shape
|
|
/// (<c>current_user_id</c>, <c>verifier_user_id</c>, <c>value</c>).
|
|
/// See Contracts-011 for the credential-sensitivity comment on
|
|
/// <c>WriteSecuredBulkEntry.value</c>.
|
|
/// </summary>
|
|
[Fact]
|
|
public void WriteSecuredBulkCommand_RoundTripsCredentialBearingEntries()
|
|
{
|
|
var original = new MxCommand
|
|
{
|
|
Kind = MxCommandKind.WriteSecuredBulk,
|
|
WriteSecuredBulk = new WriteSecuredBulkCommand
|
|
{
|
|
ServerHandle = 10,
|
|
Entries =
|
|
{
|
|
new WriteSecuredBulkEntry
|
|
{
|
|
ItemHandle = 21,
|
|
CurrentUserId = 100,
|
|
VerifierUserId = 200,
|
|
Value = new MxValue
|
|
{
|
|
DataType = MxDataType.Float,
|
|
FloatValue = 75.0f,
|
|
VariantType = "VT_R4",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
};
|
|
|
|
var parsed = MxCommand.Parser.ParseFrom(original.ToByteArray());
|
|
|
|
Assert.Equal(original, parsed);
|
|
Assert.Equal(MxCommand.PayloadOneofCase.WriteSecuredBulk, parsed.PayloadCase);
|
|
Assert.Equal(100, parsed.WriteSecuredBulk.Entries[0].CurrentUserId);
|
|
Assert.Equal(200, parsed.WriteSecuredBulk.Entries[0].VerifierUserId);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that a <see cref="WriteSecured2BulkCommand"/> round-trips,
|
|
/// including both the credential-sensitive <c>value</c> and the
|
|
/// <c>timestamp_value</c> per entry.
|
|
/// </summary>
|
|
[Fact]
|
|
public void WriteSecured2BulkCommand_RoundTripsCredentialBearingEntriesWithTimestamp()
|
|
{
|
|
var timestamp = Timestamp.FromDateTime(new DateTime(2026, 5, 19, 11, 30, 0, DateTimeKind.Utc));
|
|
var original = new MxCommand
|
|
{
|
|
Kind = MxCommandKind.WriteSecured2Bulk,
|
|
WriteSecured2Bulk = new WriteSecured2BulkCommand
|
|
{
|
|
ServerHandle = 10,
|
|
Entries =
|
|
{
|
|
new WriteSecured2BulkEntry
|
|
{
|
|
ItemHandle = 21,
|
|
CurrentUserId = 100,
|
|
VerifierUserId = 200,
|
|
Value = new MxValue
|
|
{
|
|
DataType = MxDataType.Float,
|
|
FloatValue = 50.0f,
|
|
VariantType = "VT_R4",
|
|
},
|
|
TimestampValue = new MxValue
|
|
{
|
|
DataType = MxDataType.Time,
|
|
TimestampValue = timestamp,
|
|
VariantType = "VT_DATE",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
};
|
|
|
|
var parsed = MxCommand.Parser.ParseFrom(original.ToByteArray());
|
|
|
|
Assert.Equal(original, parsed);
|
|
Assert.Equal(MxCommand.PayloadOneofCase.WriteSecured2Bulk, parsed.PayloadCase);
|
|
Assert.NotNull(parsed.WriteSecured2Bulk.Entries[0].TimestampValue);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that a <see cref="ReadBulkCommand"/> round-trips, including
|
|
/// the <c>tag_addresses</c> list and the <c>timeout_ms</c> field that
|
|
/// distinguishes the cached-vs-snapshot lifecycle.
|
|
/// </summary>
|
|
[Fact]
|
|
public void ReadBulkCommand_RoundTripsTagAddressesAndTimeout()
|
|
{
|
|
var original = new MxCommand
|
|
{
|
|
Kind = MxCommandKind.ReadBulk,
|
|
ReadBulk = new ReadBulkCommand
|
|
{
|
|
ServerHandle = 10,
|
|
TagAddresses = { "Provider!Tank01.Level", "Provider!Tank02.Level" },
|
|
TimeoutMs = 2500,
|
|
},
|
|
};
|
|
|
|
var parsed = MxCommand.Parser.ParseFrom(original.ToByteArray());
|
|
|
|
Assert.Equal(original, parsed);
|
|
Assert.Equal(MxCommand.PayloadOneofCase.ReadBulk, parsed.PayloadCase);
|
|
Assert.Equal(2, parsed.ReadBulk.TagAddresses.Count);
|
|
Assert.Equal(2500u, parsed.ReadBulk.TimeoutMs);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that a <see cref="BulkWriteReply"/> carrying mixed-outcome
|
|
/// <see cref="BulkWriteResult"/> entries round-trips and that the
|
|
/// proto3 <c>optional int32 hresult</c> presence flag survives both the
|
|
/// "hresult set" and "hresult unset" cases.
|
|
/// </summary>
|
|
[Fact]
|
|
public void BulkWriteReply_RoundTripsResultsWithOptionalHresultPresence()
|
|
{
|
|
var original = new BulkWriteReply
|
|
{
|
|
Results =
|
|
{
|
|
new BulkWriteResult
|
|
{
|
|
ServerHandle = 10,
|
|
ItemHandle = 21,
|
|
WasSuccessful = true,
|
|
Hresult = 0,
|
|
Statuses =
|
|
{
|
|
new MxStatusProxy
|
|
{
|
|
Success = 1,
|
|
Category = MxStatusCategory.Ok,
|
|
DetectedBy = MxStatusSource.RespondingLmx,
|
|
},
|
|
},
|
|
},
|
|
new BulkWriteResult
|
|
{
|
|
ServerHandle = 10,
|
|
ItemHandle = 22,
|
|
WasSuccessful = false,
|
|
Hresult = unchecked((int)0x80004005),
|
|
ErrorMessage = "item not advised",
|
|
},
|
|
new BulkWriteResult
|
|
{
|
|
ServerHandle = 10,
|
|
ItemHandle = 23,
|
|
WasSuccessful = false,
|
|
// Hresult deliberately UNSET — exercises the proto3
|
|
// `optional int32` HasField() = false arm.
|
|
ErrorMessage = "tag rejected by allowlist",
|
|
},
|
|
},
|
|
};
|
|
|
|
var parsed = BulkWriteReply.Parser.ParseFrom(original.ToByteArray());
|
|
|
|
Assert.Equal(original, parsed);
|
|
Assert.Equal(3, parsed.Results.Count);
|
|
Assert.True(parsed.Results[0].HasHresult);
|
|
Assert.True(parsed.Results[1].HasHresult);
|
|
Assert.False(parsed.Results[2].HasHresult);
|
|
Assert.True(parsed.Results[0].WasSuccessful);
|
|
Assert.False(parsed.Results[2].WasSuccessful);
|
|
Assert.Single(parsed.Results[0].Statuses);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that a <see cref="BulkReadReply"/> carrying both cached
|
|
/// (<c>was_cached = true</c>) and uncached (<c>was_cached = false</c>)
|
|
/// <see cref="BulkReadResult"/> entries round-trips. Pins the
|
|
/// deliberate absence of <c>hresult</c> on <see cref="BulkReadResult"/>
|
|
/// — failures are carried as <c>was_successful = false</c> plus
|
|
/// <c>error_message</c> only.
|
|
/// </summary>
|
|
[Fact]
|
|
public void BulkReadReply_RoundTripsCachedAndSnapshotResults()
|
|
{
|
|
var sourceTimestamp = Timestamp.FromDateTime(new DateTime(2026, 5, 19, 12, 0, 0, DateTimeKind.Utc));
|
|
var original = new BulkReadReply
|
|
{
|
|
Results =
|
|
{
|
|
new BulkReadResult
|
|
{
|
|
ServerHandle = 10,
|
|
TagAddress = "Provider!Tank01.Level",
|
|
ItemHandle = 21,
|
|
WasSuccessful = true,
|
|
WasCached = true,
|
|
Value = new MxValue
|
|
{
|
|
DataType = MxDataType.Float,
|
|
FloatValue = 42.5f,
|
|
VariantType = "VT_R4",
|
|
},
|
|
Quality = 192,
|
|
SourceTimestamp = sourceTimestamp,
|
|
Statuses =
|
|
{
|
|
new MxStatusProxy
|
|
{
|
|
Success = 1,
|
|
Category = MxStatusCategory.Ok,
|
|
DetectedBy = MxStatusSource.RespondingNmx,
|
|
},
|
|
},
|
|
},
|
|
new BulkReadResult
|
|
{
|
|
ServerHandle = 10,
|
|
TagAddress = "Provider!Tank02.Level",
|
|
ItemHandle = 22,
|
|
WasSuccessful = true,
|
|
WasCached = false,
|
|
Value = new MxValue
|
|
{
|
|
DataType = MxDataType.Integer,
|
|
Int32Value = 0,
|
|
VariantType = "VT_I4",
|
|
},
|
|
SourceTimestamp = sourceTimestamp,
|
|
},
|
|
new BulkReadResult
|
|
{
|
|
ServerHandle = 10,
|
|
TagAddress = "Provider!Bad.Tag",
|
|
WasSuccessful = false,
|
|
WasCached = false,
|
|
ErrorMessage = "snapshot timed out before first OnDataChange",
|
|
},
|
|
},
|
|
};
|
|
|
|
var parsed = BulkReadReply.Parser.ParseFrom(original.ToByteArray());
|
|
|
|
Assert.Equal(original, parsed);
|
|
Assert.Equal(3, parsed.Results.Count);
|
|
Assert.True(parsed.Results[0].WasCached);
|
|
Assert.False(parsed.Results[1].WasCached);
|
|
Assert.False(parsed.Results[2].WasSuccessful);
|
|
Assert.Equal("snapshot timed out before first OnDataChange", parsed.Results[2].ErrorMessage);
|
|
// BulkReadResult has no `hresult` field — pin that contract.
|
|
Assert.DoesNotContain(
|
|
BulkReadResult.Descriptor.Fields.InDeclarationOrder(),
|
|
field => field.Name == "hresult");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that an <see cref="MxCommandReply"/> with each of the new
|
|
/// bulk write/read payload oneof cases round-trips and that
|
|
/// <see cref="MxCommandReply.PayloadOneofCase"/> resolves to the
|
|
/// expected value. Pins every new oneof case added by the bulk
|
|
/// write/read extension.
|
|
/// </summary>
|
|
/// <param name="kind">The command kind to test.</param>
|
|
/// <param name="expectedPayloadCase">The expected payload oneof case.</param>
|
|
[Theory]
|
|
[InlineData(MxCommandKind.WriteBulk, MxCommandReply.PayloadOneofCase.WriteBulk)]
|
|
[InlineData(MxCommandKind.Write2Bulk, MxCommandReply.PayloadOneofCase.Write2Bulk)]
|
|
[InlineData(MxCommandKind.WriteSecuredBulk, MxCommandReply.PayloadOneofCase.WriteSecuredBulk)]
|
|
[InlineData(MxCommandKind.WriteSecured2Bulk, MxCommandReply.PayloadOneofCase.WriteSecured2Bulk)]
|
|
public void MxCommandReply_RoundTripsBulkWritePayloadCases(
|
|
MxCommandKind kind,
|
|
MxCommandReply.PayloadOneofCase expectedPayloadCase)
|
|
{
|
|
var reply = new BulkWriteReply
|
|
{
|
|
Results =
|
|
{
|
|
new BulkWriteResult
|
|
{
|
|
ServerHandle = 5,
|
|
ItemHandle = 7,
|
|
WasSuccessful = true,
|
|
Hresult = 0,
|
|
},
|
|
},
|
|
};
|
|
var original = new MxCommandReply
|
|
{
|
|
SessionId = "session-1",
|
|
CorrelationId = "gateway-correlation-bulk-write",
|
|
Kind = kind,
|
|
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
|
Hresult = 0,
|
|
};
|
|
switch (expectedPayloadCase)
|
|
{
|
|
case MxCommandReply.PayloadOneofCase.WriteBulk:
|
|
original.WriteBulk = reply;
|
|
break;
|
|
case MxCommandReply.PayloadOneofCase.Write2Bulk:
|
|
original.Write2Bulk = reply;
|
|
break;
|
|
case MxCommandReply.PayloadOneofCase.WriteSecuredBulk:
|
|
original.WriteSecuredBulk = reply;
|
|
break;
|
|
case MxCommandReply.PayloadOneofCase.WriteSecured2Bulk:
|
|
original.WriteSecured2Bulk = reply;
|
|
break;
|
|
default:
|
|
throw new System.ArgumentOutOfRangeException(nameof(expectedPayloadCase));
|
|
}
|
|
|
|
var parsed = MxCommandReply.Parser.ParseFrom(original.ToByteArray());
|
|
|
|
Assert.Equal(original, parsed);
|
|
Assert.Equal(expectedPayloadCase, parsed.PayloadCase);
|
|
Assert.Equal(kind, parsed.Kind);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that an <see cref="AlarmFeedMessage"/> carrying the
|
|
/// <c>provider_status</c> payload case round-trips and resolves to
|
|
/// <see cref="AlarmFeedMessage.PayloadOneofCase.ProviderStatus"/>.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Feed_RoundTripsProviderStatus()
|
|
{
|
|
var since = Timestamp.FromDateTime(new DateTime(2026, 6, 13, 9, 0, 0, DateTimeKind.Utc));
|
|
var original = new AlarmFeedMessage
|
|
{
|
|
ProviderStatus = new AlarmProviderStatus
|
|
{
|
|
Mode = AlarmProviderMode.Subtag,
|
|
Degraded = true,
|
|
Reason = "wnwrap poll failed 3x (HRESULT 0x80004005)",
|
|
Since = since,
|
|
},
|
|
};
|
|
|
|
var parsed = AlarmFeedMessage.Parser.ParseFrom(original.ToByteArray());
|
|
|
|
Assert.Equal(original, parsed);
|
|
Assert.Equal(AlarmFeedMessage.PayloadOneofCase.ProviderStatus, parsed.PayloadCase);
|
|
Assert.True(parsed.ProviderStatus.Degraded);
|
|
Assert.Equal(AlarmProviderMode.Subtag, parsed.ProviderStatus.Mode);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that an <see cref="OnAlarmTransitionEvent"/> carrying the
|
|
/// new <c>degraded</c> and <c>source_provider</c> provenance fields
|
|
/// round-trips with their values preserved.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Transition_RoundTripsDegradedProvenance()
|
|
{
|
|
var t = new OnAlarmTransitionEvent
|
|
{
|
|
AlarmFullReference = "Galaxy!Area.Tank01.Level.HiHi",
|
|
TransitionKind = AlarmTransitionKind.Raise,
|
|
Degraded = true,
|
|
SourceProvider = AlarmProviderMode.Subtag,
|
|
};
|
|
|
|
var parsed = OnAlarmTransitionEvent.Parser.ParseFrom(t.ToByteArray());
|
|
|
|
Assert.True(parsed.Degraded);
|
|
Assert.Equal(AlarmProviderMode.Subtag, parsed.SourceProvider);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that an <see cref="MxCommandReply"/> with kind
|
|
/// <see cref="MxCommandKind.ReadBulk"/> and a populated
|
|
/// <see cref="BulkReadReply"/> payload round-trips and resolves to
|
|
/// <see cref="MxCommandReply.PayloadOneofCase.ReadBulk"/>.
|
|
/// </summary>
|
|
[Fact]
|
|
public void MxCommandReply_RoundTripsReadBulkPayload()
|
|
{
|
|
var original = new MxCommandReply
|
|
{
|
|
SessionId = "session-1",
|
|
CorrelationId = "gateway-correlation-read-bulk",
|
|
Kind = MxCommandKind.ReadBulk,
|
|
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
|
Hresult = 0,
|
|
ReadBulk = new BulkReadReply
|
|
{
|
|
Results =
|
|
{
|
|
new BulkReadResult
|
|
{
|
|
ServerHandle = 5,
|
|
TagAddress = "Provider!Tank01.Level",
|
|
ItemHandle = 7,
|
|
WasSuccessful = true,
|
|
WasCached = true,
|
|
Value = new MxValue
|
|
{
|
|
DataType = MxDataType.Float,
|
|
FloatValue = 12.5f,
|
|
VariantType = "VT_R4",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
};
|
|
|
|
var parsed = MxCommandReply.Parser.ParseFrom(original.ToByteArray());
|
|
|
|
Assert.Equal(original, parsed);
|
|
Assert.Equal(MxCommandReply.PayloadOneofCase.ReadBulk, parsed.PayloadCase);
|
|
Assert.Single(parsed.ReadBulk.Results);
|
|
Assert.True(parsed.ReadBulk.Results[0].WasCached);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that an <see cref="ActiveAlarmSnapshot"/> carrying the
|
|
/// alarm-provider provenance fields <c>degraded</c> (14) and
|
|
/// <c>source_provider</c> (15) round-trips with their values preserved,
|
|
/// pinning the wire shape of the byte-identical provenance fields that
|
|
/// also appear on <see cref="OnAlarmTransitionEvent"/>.
|
|
/// </summary>
|
|
[Fact]
|
|
public void ActiveAlarmSnapshot_RoundTripsDegradedProvenance()
|
|
{
|
|
var raise = Timestamp.FromDateTime(new DateTime(2026, 6, 13, 12, 0, 0, DateTimeKind.Utc));
|
|
var original = new ActiveAlarmSnapshot
|
|
{
|
|
AlarmFullReference = "Galaxy!Area.Tank01.Level.HiHi",
|
|
SourceObjectReference = "Tank01",
|
|
AlarmTypeName = "AnalogLimitAlarm.HiHi",
|
|
Severity = 750,
|
|
OriginalRaiseTimestamp = raise,
|
|
CurrentState = AlarmConditionState.Active,
|
|
Degraded = true,
|
|
SourceProvider = AlarmProviderMode.Subtag,
|
|
};
|
|
|
|
var parsed = ActiveAlarmSnapshot.Parser.ParseFrom(original.ToByteArray());
|
|
|
|
Assert.Equal(original, parsed);
|
|
Assert.True(parsed.Degraded);
|
|
Assert.Equal(AlarmProviderMode.Subtag, parsed.SourceProvider);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that a <see cref="SubscribeAlarmsCommand"/> populating the
|
|
/// alarm-provider fallback extensions — <c>forced_mode</c> (2), a
|
|
/// <c>watch_list</c> entry with all six <see cref="AlarmSubtagTarget"/>
|
|
/// string fields (3), and a <c>failover</c>
|
|
/// <see cref="AlarmFailoverConfig"/> (4) — round-trips end to end,
|
|
/// pinning the wire shape that the forced-subtag-mode fix depends on.
|
|
/// </summary>
|
|
[Fact]
|
|
public void SubscribeAlarmsCommand_RoundTripsForcedModeWatchListAndFailover()
|
|
{
|
|
var original = new SubscribeAlarmsCommand
|
|
{
|
|
SubscriptionExpression = @"\\node\Galaxy!Area",
|
|
ForcedMode = AlarmProviderMode.Subtag,
|
|
WatchList =
|
|
{
|
|
new AlarmSubtagTarget
|
|
{
|
|
AlarmFullReference = "Galaxy!Area.Tank01.Level.HiHi",
|
|
SourceObjectReference = "Tank01",
|
|
ActiveSubtag = "Tank01.Level.HiHi.InAlarm",
|
|
AckedSubtag = "Tank01.Level.HiHi.Acked",
|
|
AckCommentSubtag = "Tank01.Level.HiHi.AckMsg",
|
|
PrioritySubtag = "Tank01.Level.HiHi.Priority",
|
|
},
|
|
},
|
|
Failover = new AlarmFailoverConfig
|
|
{
|
|
ConsecutiveFailureThreshold = 3,
|
|
FailbackProbeIntervalSeconds = 10,
|
|
FailbackStableProbes = 5,
|
|
},
|
|
};
|
|
|
|
var parsed = SubscribeAlarmsCommand.Parser.ParseFrom(original.ToByteArray());
|
|
|
|
Assert.Equal(original, parsed);
|
|
Assert.Equal(AlarmProviderMode.Subtag, parsed.ForcedMode);
|
|
var target = Assert.Single(parsed.WatchList);
|
|
Assert.Equal("Galaxy!Area.Tank01.Level.HiHi", target.AlarmFullReference);
|
|
Assert.Equal("Tank01", target.SourceObjectReference);
|
|
Assert.Equal("Tank01.Level.HiHi.InAlarm", target.ActiveSubtag);
|
|
Assert.Equal("Tank01.Level.HiHi.Acked", target.AckedSubtag);
|
|
Assert.Equal("Tank01.Level.HiHi.AckMsg", target.AckCommentSubtag);
|
|
Assert.Equal("Tank01.Level.HiHi.Priority", target.PrioritySubtag);
|
|
Assert.Equal(3, parsed.Failover.ConsecutiveFailureThreshold);
|
|
Assert.Equal(10, parsed.Failover.FailbackProbeIntervalSeconds);
|
|
Assert.Equal(5, parsed.Failover.FailbackStableProbes);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that an <see cref="MxEvent"/> carrying an
|
|
/// <see cref="OnAlarmProviderModeChangedEvent"/> body (the
|
|
/// <c>MxEvent.body</c> oneof tag 25 paired with
|
|
/// <see cref="MxEventFamily.OnAlarmProviderModeChanged"/>, family 6)
|
|
/// round-trips and resolves to
|
|
/// <see cref="MxEvent.BodyOneofCase.OnAlarmProviderModeChanged"/>.
|
|
/// </summary>
|
|
[Fact]
|
|
public void MxEvent_RoundTripsOnAlarmProviderModeChangedBody()
|
|
{
|
|
var at = Timestamp.FromDateTime(new DateTime(2026, 6, 13, 9, 30, 0, DateTimeKind.Utc));
|
|
var original = new MxEvent
|
|
{
|
|
Family = MxEventFamily.OnAlarmProviderModeChanged,
|
|
SessionId = "session-1",
|
|
WorkerSequence = 42,
|
|
OnAlarmProviderModeChanged = new OnAlarmProviderModeChangedEvent
|
|
{
|
|
Mode = AlarmProviderMode.Subtag,
|
|
Reason = "wnwrap poll failed 3x",
|
|
Hresult = unchecked((int)0x80004005),
|
|
At = at,
|
|
},
|
|
};
|
|
|
|
var parsed = MxEvent.Parser.ParseFrom(original.ToByteArray());
|
|
|
|
Assert.Equal(original, parsed);
|
|
Assert.Equal(MxEvent.BodyOneofCase.OnAlarmProviderModeChanged, parsed.BodyCase);
|
|
Assert.Equal(MxEventFamily.OnAlarmProviderModeChanged, parsed.Family);
|
|
Assert.Equal(AlarmProviderMode.Subtag, parsed.OnAlarmProviderModeChanged.Mode);
|
|
Assert.Equal(unchecked((int)0x80004005), parsed.OnAlarmProviderModeChanged.Hresult);
|
|
}
|
|
}
|