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 _)); } /// /// Verifies the exact-format fast path PERF-20 added in front of the /// general parse chain returns exactly what that chain returned. The /// cases cover the captured MXAccess shape (which the fast path is meant /// to catch on a US-shaped host), its zero-padded and second-precision /// siblings, and strings the derived formats cannot match — an ISO /// round-trip form, a long-date form, and a day-first European form — /// which must fall through to the unchanged two-stage chain. The /// expectation is computed with the pre-PERF-20 chain in this test, so it /// holds on any host culture: the fast path is a shortcut, never a /// different answer. /// /// Timestamp string to parse. [Theory] [InlineData("3/26/2026 1:38:22.907 PM")] [InlineData("3/26/2026 1:38:22 PM")] [InlineData("03/26/2026 01:38:22 PM")] [InlineData("12/31/2026 11:59:59.999 PM")] [InlineData("2026-03-26T13:38:22.9070000")] [InlineData("Thursday, March 26, 2026 1:38:22 PM")] [InlineData("26.03.2026 13:38:22")] [InlineData("not a timestamp")] public void TryParseSourceTimestamp_MatchesPreFastPathChain(string text) { bool expectedParsed = TryParseWithGeneralChainOnly(text, out DateTime expectedUtc); bool parsed = MxAccessEventMapper.TryParseSourceTimestamp(text, out DateTime utc); Assert.Equal(expectedParsed, parsed); Assert.Equal(expectedUtc, utc); if (parsed) { Assert.Equal(DateTimeKind.Utc, utc.Kind); } } /// /// Verifies a timestamp written in the host culture's own patterns — /// including the millisecond fraction MXAccess appends, which no culture /// publishes in its long-time pattern — parses as local wall-clock time /// and comes back as UTC. This is the string shape the derived exact /// formats are built for, so it exercises the fast path on the worker's /// own host regardless of which culture that host runs. /// [Fact] public void TryParseSourceTimestamp_WithCultureShapedMillisecondTimestamp_ReturnsLocalWallClockAsUtc() { CultureInfo culture = CultureInfo.CurrentCulture; string longTime = culture.DateTimeFormat.LongTimePattern; int secondsIndex = longTime.IndexOf("ss", StringComparison.Ordinal); // Every Windows culture publishes seconds in its long-time pattern; the // guard keeps the test honest rather than green-by-accident if one does not. Assert.True(secondsIndex >= 0, $"Long-time pattern '{longTime}' has no seconds specifier."); DateTime localWall = new(2026, 3, 26, 13, 38, 22, 907, DateTimeKind.Unspecified); string text = localWall.ToString( culture.DateTimeFormat.ShortDatePattern + " " + longTime.Insert(secondsIndex + 2, ".fff"), culture); Assert.True(MxAccessEventMapper.TryParseSourceTimestamp(text, out DateTime utc)); Assert.Equal(DateTimeKind.Utc, utc.Kind); Assert.Equal(DateTime.SpecifyKind(localWall, DateTimeKind.Local).ToUniversalTime(), utc); } /// /// Verifies format derivation survives a culture whose long-time pattern /// hides an "ss" inside quoted literal text — the case where blindly /// inserting ".fff" at the first "ss" produces a format that mangles the /// value or is outright malformed, and a malformed format makes exact /// parsing throw rather than report failure. Derivation must reject such /// a candidate at build time, so parsing here stays exception-free and /// the culture's own published shape still reads as local wall-clock /// time. Uses a culture no other test touches, because the format cache /// compares cultures by name. /// [Fact] public void TryParseSourceTimestamp_WithLiteralSecondsInCulturePattern_ParsesWithoutThrowing() { CultureInfo pathological = new("en-ZA") { DateTimeFormat = { ShortDatePattern = "M/d/yyyy", LongTimePattern = "'sss' HH:mm:ss", }, }; CultureInfo original = CultureInfo.CurrentCulture; try { CultureInfo.CurrentCulture = pathological; DateTime localWall = new(2026, 3, 26, 13, 38, 22, DateTimeKind.Unspecified); // The culture's published shape: "3/26/2026 sss 13:38:22". string published = localWall.ToString( pathological.DateTimeFormat.ShortDatePattern + " " + pathological.DateTimeFormat.LongTimePattern, pathological); Assert.True(MxAccessEventMapper.TryParseSourceTimestamp(published, out DateTime utc)); Assert.Equal(DateTimeKind.Utc, utc.Kind); Assert.Equal(DateTime.SpecifyKind(localWall, DateTimeKind.Local).ToUniversalTime(), utc); // Shapes this culture cannot describe must still fail quietly rather // than throw out of the derived formats. Assert.False(MxAccessEventMapper.TryParseSourceTimestamp("not a timestamp", out _)); Assert.False(MxAccessEventMapper.TryParseSourceTimestamp("sss sss sss", out _)); } finally { CultureInfo.CurrentCulture = original; } } /// /// The parse chain exactly as it stood before PERF-20 added the /// exact-format stage, used as the parity oracle above. /// /// Timestamp string to parse. /// The parsed UTC timestamp on success. /// when the string parsed successfully. private static bool TryParseWithGeneralChainOnly(string? text, out DateTime utc) { utc = default; if (string.IsNullOrWhiteSpace(text)) { return false; } const DateTimeStyles styles = DateTimeStyles.AssumeLocal | DateTimeStyles.AdjustToUniversal; if (DateTime.TryParse(text, CultureInfo.CurrentCulture, styles, out DateTime parsed) || DateTime.TryParse(text, CultureInfo.InvariantCulture, styles, out parsed)) { utc = DateTime.SpecifyKind(parsed, DateTimeKind.Utc); return true; } return false; } private sealed class FakeStatus { public int success; public int category; public int detectedBy; public int detail; } }