using System; using System.Globalization; using ZB.MOM.WW.MxGateway.Contracts.Proto; using ZB.MOM.WW.MxGateway.Worker.MxAccess; namespace ZB.MOM.WW.MxGateway.Worker.Tests.MxAccess; public sealed class MxAccessEventMapperTests { private readonly MxAccessEventMapper mapper = new(); /// Verifies that creating an OnDataChange event converts value, timestamp, quality, and statuses. [Fact] public void CreateOnDataChange_ConvertsValueTimestampQualityAndStatuses() { DateTime timestamp = new(2026, 4, 26, 12, 30, 0, DateTimeKind.Utc); FakeStatus[] statuses = { new() { success = -1, category = 0, detectedBy = 5, detail = 0, }, }; MxEvent mxEvent = mapper.CreateOnDataChange( "session-1", serverHandle: 12, itemHandle: 34, value: 42, quality: 192, timestamp: timestamp, statuses: statuses); Assert.Equal(MxEventFamily.OnDataChange, mxEvent.Family); Assert.Equal("session-1", mxEvent.SessionId); Assert.Equal(12, mxEvent.ServerHandle); Assert.Equal(34, mxEvent.ItemHandle); Assert.Equal(42, mxEvent.Value.Int32Value); Assert.Equal(192, mxEvent.Quality); Assert.Equal(timestamp, mxEvent.SourceTimestamp.ToDateTime()); Assert.Equal(MxEvent.BodyOneofCase.OnDataChange, mxEvent.BodyCase); MxStatusProxy status = Assert.Single(mxEvent.Statuses); Assert.Equal(-1, status.Success); Assert.Equal(MxStatusCategory.Ok, status.Category); Assert.Equal(MxStatusSource.RespondingAutomationObject, status.DetectedBy); } /// Verifies that OnWriteComplete and OperationComplete events preserve distinct families. [Fact] public void CreateOnWriteCompleteAndOperationComplete_PreservesDistinctFamilies() { MxEvent writeComplete = mapper.CreateOnWriteComplete( "session-1", serverHandle: 1, itemHandle: 2, statuses: Array.Empty()); MxEvent operationComplete = mapper.CreateOperationComplete( "session-1", serverHandle: 1, itemHandle: 2, statuses: Array.Empty()); Assert.Equal(MxEventFamily.OnWriteComplete, writeComplete.Family); Assert.Equal(MxEvent.BodyOneofCase.OnWriteComplete, writeComplete.BodyCase); Assert.Equal(MxEventFamily.OperationComplete, operationComplete.Family); Assert.Equal(MxEvent.BodyOneofCase.OperationComplete, operationComplete.BodyCase); } /// Verifies that OnBufferedDataChange events preserve raw data type and array metadata. [Fact] public void CreateOnBufferedDataChange_PreservesRawDataTypeAndArrayMetadata() { DateTime firstTimestamp = new(2026, 4, 26, 13, 0, 0, DateTimeKind.Utc); DateTime secondTimestamp = new(2026, 4, 26, 13, 1, 0, DateTimeKind.Utc); MxEvent mxEvent = mapper.CreateOnBufferedDataChange( "session-1", serverHandle: 10, itemHandle: 20, rawDataType: 2, value: new[] { 7, 8 }, quality: new[] { 192, 0 }, timestamp: new[] { firstTimestamp, secondTimestamp }, statuses: null); Assert.Equal(MxEventFamily.OnBufferedDataChange, mxEvent.Family); Assert.Equal(MxDataType.Integer, mxEvent.OnBufferedDataChange.DataType); Assert.Equal(2, mxEvent.OnBufferedDataChange.RawDataType); Assert.Equal(MxDataType.Integer, mxEvent.Value.ArrayValue.ElementDataType); Assert.Equal(new[] { 7, 8 }, mxEvent.Value.ArrayValue.Int32Values.Values); Assert.Equal(new[] { 192, 0 }, mxEvent.OnBufferedDataChange.QualityValues.Int32Values.Values); Assert.Equal(2, mxEvent.OnBufferedDataChange.TimestampValues.TimestampValues.Values.Count); } /// Verifies that MapMxDataType maps raw MXAccess data types to protobuf enum values. /// Raw MXAccess data type value to map. /// Expected MxDataType enum value. [Theory] [InlineData(-1, MxDataType.Unknown)] [InlineData(0, MxDataType.NoData)] [InlineData(1, MxDataType.Boolean)] [InlineData(2, MxDataType.Integer)] [InlineData(6, MxDataType.Time)] [InlineData(15, MxDataType.InternationalizedString)] [InlineData(999, MxDataType.Unknown)] public void MapMxDataType_MapsInstalledMxAccessValues( int rawDataType, MxDataType expectedDataType) { Assert.Equal(expectedDataType, MxAccessEventMapper.MapMxDataType(rawDataType)); } /// Verifies CreateOnAlarmTransition packs the full alarm payload. [Fact] public void CreateOnAlarmTransition_PopulatesFullPayload() { DateTime raise = new(2026, 5, 1, 12, 0, 0, DateTimeKind.Utc); DateTime ack = raise.AddSeconds(45); MxEvent mxEvent = mapper.CreateOnAlarmTransition( sessionId: "session-1", alarmFullReference: "Tank01.Level.HiHi", sourceObjectReference: "Tank01", alarmTypeName: "AnalogLimitAlarm.HiHi", transitionKind: AlarmTransitionKind.Acknowledge, severity: 750, originalRaiseTimestampUtc: raise, transitionTimestampUtc: ack, operatorUser: "alice", operatorComment: "investigating", category: "Process", description: "Tank 01 high-high level", statuses: null); Assert.Equal(MxEventFamily.OnAlarmTransition, mxEvent.Family); Assert.Equal(MxEvent.BodyOneofCase.OnAlarmTransition, mxEvent.BodyCase); OnAlarmTransitionEvent body = mxEvent.OnAlarmTransition; Assert.Equal("Tank01.Level.HiHi", body.AlarmFullReference); Assert.Equal("Tank01", body.SourceObjectReference); Assert.Equal("AnalogLimitAlarm.HiHi", body.AlarmTypeName); Assert.Equal(AlarmTransitionKind.Acknowledge, body.TransitionKind); Assert.Equal(750, body.Severity); Assert.Equal(raise, body.OriginalRaiseTimestamp.ToDateTime()); Assert.Equal(ack, body.TransitionTimestamp.ToDateTime()); Assert.Equal("alice", body.OperatorUser); Assert.Equal("investigating", body.OperatorComment); Assert.Equal("Process", body.Category); Assert.Equal("Tank 01 high-high level", body.Description); } /// Verifies CreateOnAlarmTransition handles a Raise transition with no operator metadata. [Fact] public void CreateOnAlarmTransition_RaiseTransitionLeavesOperatorFieldsEmpty() { DateTime raise = new(2026, 5, 1, 12, 0, 0, DateTimeKind.Utc); MxEvent mxEvent = mapper.CreateOnAlarmTransition( sessionId: "session-1", alarmFullReference: "Tank01.Level.HiHi", sourceObjectReference: "Tank01", alarmTypeName: "AnalogLimitAlarm.HiHi", transitionKind: AlarmTransitionKind.Raise, severity: 750, originalRaiseTimestampUtc: null, transitionTimestampUtc: raise, operatorUser: string.Empty, operatorComment: string.Empty, category: "Process", description: "Tank 01 high-high level", statuses: null); Assert.Equal(AlarmTransitionKind.Raise, mxEvent.OnAlarmTransition.TransitionKind); Assert.Equal(string.Empty, mxEvent.OnAlarmTransition.OperatorUser); Assert.Equal(string.Empty, mxEvent.OnAlarmTransition.OperatorComment); Assert.Null(mxEvent.OnAlarmTransition.OriginalRaiseTimestamp); } /// /// Verifies that an OnDataChange whose timestamp arrives as the /// VT_BSTR string MXAccess actually delivers still populates /// — the string is parsed as /// local time and converted to UTC. /// [Fact] public void CreateOnDataChange_WithMxAccessStringTimestamp_SetsSourceTimestamp() { // The exact shape MXAccess fires (see captures/003-subscribe-scalars). const string mxAccessTimestamp = "3/26/2026 1:38:22.907 PM"; MxEvent mxEvent = mapper.CreateOnDataChange( "session-1", serverHandle: 1, itemHandle: 1, value: 99, quality: 192, timestamp: mxAccessTimestamp, statuses: null); Assert.NotNull(mxEvent.SourceTimestamp); DateTime localWall = new(2026, 3, 26, 13, 38, 22, 907, DateTimeKind.Unspecified); DateTime expectedUtc = DateTime.SpecifyKind(localWall, DateTimeKind.Local).ToUniversalTime(); Assert.Equal(expectedUtc, mxEvent.SourceTimestamp.ToDateTime()); } /// /// Verifies the MXAccess timestamp string is interpreted as the host's /// local time and returned as UTC. Written timezone-independently by /// round-tripping a local wall-clock time. /// [Fact] public void TryParseSourceTimestamp_InterpretsStringAsLocalTime() { DateTime localWall = new(2026, 5, 21, 13, 43, 26, DateTimeKind.Unspecified); string text = localWall.ToString(CultureInfo.CurrentCulture); Assert.True(MxAccessEventMapper.TryParseSourceTimestamp(text, out DateTime utc)); Assert.Equal(DateTimeKind.Utc, utc.Kind); DateTime expectedUtc = DateTime.SpecifyKind(localWall, DateTimeKind.Local).ToUniversalTime(); Assert.Equal(expectedUtc, utc); } /// Verifies unparseable or empty timestamp input is rejected without throwing. /// Unparseable or empty timestamp string. [Theory] [InlineData(null)] [InlineData("")] [InlineData(" ")] [InlineData("not a timestamp")] public void TryParseSourceTimestamp_RejectsUnparseableInput(string? text) { Assert.False(MxAccessEventMapper.TryParseSourceTimestamp(text, out _)); } private sealed class FakeStatus { public int success; public int category; public int detectedBy; public int detail; } }