using Google.Protobuf.WellKnownTypes; using Xunit; using ZB.MOM.WW.HistorianGateway.Contracts.Grpc; using ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Mapping; namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests.Mapping; public sealed class EventMapperTests { [Fact] public void Carries_the_alarm_type_condition_name_and_alarm_id_the_writer_sent() { // The round trip of AlarmEventMapper's shape: Type = AlarmTypeName, Properties AlarmName / AlarmId. var sent = AlarmEventMapper.ToHistorianEvent(new ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.AlarmHistorianEvent( "Plant/Modbus/dev1/temp_hi", "Area/Line/Eq", "HiTemp", "LimitAlarm", ZB.MOM.WW.OtOpcUa.Core.Abstractions.AlarmSeverity.High, "Activated", "high", "u", null, new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc))); var h = EventMapper.ToHistoricalEvent(sent); Assert.Equal("LimitAlarm", h.EventTypeName); Assert.Equal("HiTemp", h.ConditionName); Assert.Equal("Plant/Modbus/dev1/temp_hi", h.AlarmId); // A gateway row without them (e.g. an event another writer produced) maps to nulls, never throws. var bare = new HistorianEvent { Id = "E", EventTime = Ts(2026, 1, 1, 0, 0, 0), ReceivedTime = Ts(2026, 1, 1, 0, 0, 0) }; var b = EventMapper.ToHistoricalEvent(bare); Assert.Null(b.EventTypeName); Assert.Null(b.ConditionName); Assert.Null(b.AlarmId); } [Fact] public void Maps_core_fields_and_times() { var e = new HistorianEvent { Id = "E1", SourceName = "Pump1", EventTime = Ts(2026, 1, 1, 0, 0, 0), ReceivedTime = Ts(2026, 1, 1, 0, 0, 5) }; e.Properties["Message"] = "High temp"; e.Properties["Severity"] = "700"; var h = EventMapper.ToHistoricalEvent(e); Assert.Equal("E1", h.EventId); Assert.Equal("Pump1", h.SourceName); Assert.Equal("High temp", h.Message); Assert.Equal((ushort)700, h.Severity); Assert.Equal(DateTimeKind.Utc, h.EventTimeUtc.Kind); } [Theory] [InlineData("Priority", "999", 250)] // a Galaxy priority maps through the live table (#886/#887: 999 = least urgent), not clamped as a severity [InlineData("Priority", "1", 1000)] // AVEVA priority 1 is the most urgent (#887) [InlineData("Severity", "0", 1)] // clamp to OPC UA min 1 [InlineData("Severity", "5000", 1000)] // clamp to OPC UA max 1000 [InlineData(null, null, 1)] // missing → default min severity public void Severity_parsed_and_clamped(string? key, string? val, int expected) { var e = new HistorianEvent { Id = "E", EventTime = Ts(2026, 1, 1, 0, 0, 0), ReceivedTime = Ts(2026, 1, 1, 0, 0, 0) }; if (key is not null) e.Properties[key] = val!; Assert.Equal((ushort)expected, EventMapper.ToHistoricalEvent(e).Severity); } // ---- Gitea #886: AVEVA-shaped rows (lower-case keys) ------------------------------------------------------------- /// An AVEVA A&E alarm row as vd03's HistorianGateway returns it (2026-11-12 probe, trimmed). private static HistorianEvent AvevaAlarmRow(string priority = "500") { var e = new HistorianEvent { Id = "001c0040-0025-0004-5b00-74003f005400", Type = "Alarm.Set", SourceName = "Oven_007.Temp", Namespace = "Application Server", EventTime = Ts(2026, 11, 5, 15, 19, 52), ReceivedTime = Ts(2026, 11, 5, 15, 19, 53), }; e.Properties["alarm_id"] = "001c0040-0025-0004-5b00-74003f005400"; e.Properties["alarm_state"] = "UNACK_ALM"; e.Properties["comment"] = "Oven 7 temperature"; e.Properties["priority"] = priority; e.Properties["severity"] = "2"; // AVEVA's own 1..4 scale: never the OPC UA Severity e.Properties["source_conditionvariable"] = "Oven_007.TempHiAlarm"; e.Properties["source_object"] = "Oven_007"; e.Properties["source_processvariable"] = "Oven_007.Temp"; return e; } [Fact] public void An_aveva_alarm_row_maps_priority_alarm_id_and_its_condition_reference() { var h = EventMapper.ToHistoricalEvent(AvevaAlarmRow()); Assert.Equal((ushort)750, h.Severity); // priority 500 → the live Galaxy table's High bucket, not AVEVA's severity "2" Assert.Equal("001c0040-0025-0004-5b00-74003f005400", h.AlarmId); Assert.Equal("Oven_007.TempHiAlarm", h.ConditionReference); Assert.Equal("TempHiAlarm", h.ConditionName); // the AVEVA alarm's name until the server resolves a live condition Assert.Equal("Alarm.Set", h.Message); // no message property: the event type, as before Assert.Equal("Oven_007.Temp", h.SourceName); } // lmxopcua#887: AVEVA's default priority bands, most urgent first. NativeAlarmRestatementEndToEndTests // (Galaxy_live_condition_severity_is_the_AVEVA_priority_band) pins the same rows on the live path. [Theory] [InlineData("1", 1000)] // Critical 1–250 [InlineData("250", 1000)] [InlineData("251", 750)] // High 251–500 (vd03: priority 500 rows carry AVEVA severity 2) [InlineData("500", 750)] [InlineData("501", 500)] // Medium 501–750 [InlineData("750", 500)] [InlineData("751", 250)] // Low 751–999 [InlineData("999", 250)] // a priority-999 ALARM (AVEVA severity 4); a system event at 999 is the next test [InlineData("0", 1000)] // out of range: clamped to 1 [InlineData("-5", 1000)] [InlineData("1000", 250)] // out of range: clamped to 999 public void An_aveva_priority_maps_through_the_live_galaxy_table(string priority, int expected) { var h = EventMapper.ToHistoricalEvent(AvevaAlarmRow(priority)); Assert.Equal((ushort)expected, h.Severity); // The live Galaxy path maps the same raw value through the same table, so history and live agree. Assert.Equal(expected, ZB.MOM.WW.OtOpcUa.Core.Abstractions.GalaxyAlarmPriority.Map(int.Parse(priority)).OpcUaSeverity); } [Fact] public void OtOpcUas_own_event_read_back_from_aveva_with_lower_case_keys_keeps_its_fields() { // AVEVA lower-cases the keys OtOpcUa writes (AlarmName → alarmname, …): seen on vd03 2026-11-12. var e = new HistorianEvent { Id = "E", Type = "LimitAlarm", SourceName = "Area/Line/Eq", EventTime = Ts(2026, 6, 27, 3, 43, 44), ReceivedTime = Ts(2026, 6, 27, 3, 43, 44) }; e.Properties["alarmid"] = "Plant/Modbus/dev1/temp_hi"; e.Properties["alarmname"] = "HiTemp"; e.Properties["eventkind"] = "Activated"; e.Properties["message"] = "Temperature high"; e.Properties["severity"] = "700"; var h = EventMapper.ToHistoricalEvent(e); Assert.Equal("Plant/Modbus/dev1/temp_hi", h.AlarmId); Assert.Equal("HiTemp", h.ConditionName); Assert.Equal("Temperature high", h.Message); Assert.Equal((ushort)700, h.Severity); Assert.Null(h.ConditionReference); } [Fact] public void An_aveva_system_event_has_no_condition() { var e = new HistorianEvent { Id = "S", Type = "System.Stop", SourceName = "Oven_007", EventTime = Ts(2026, 11, 10, 11, 32, 35), ReceivedTime = Ts(2026, 11, 10, 11, 32, 35) }; e.Properties["priority"] = "999"; e.Properties["severity"] = "0"; e.Properties["source_object"] = "Oven_007"; var h = EventMapper.ToHistoricalEvent(e); Assert.Null(h.ConditionName); Assert.Null(h.ConditionReference); Assert.Null(h.AlarmId); Assert.Equal("System.Stop", h.Message); // lmxopcua#887: AVEVA severity 0 = not an alarm — the lowest OPC UA Severity, below every alarm band. Assert.Equal((ushort)1, h.Severity); } [Theory] [InlineData("System.Start", "999", "0", 1)] // not an alarm (vd03: System.*, User.Write rows are priority 999 / severity 0) [InlineData("User.Write", "999", "0", 1)] [InlineData("Alarm.Set", "999", "4", 250)] // positive control: a priority-999 ALARM is Low, not "not an alarm" [InlineData("Alarm.Set", "999", null, 250)] // no AVEVA severity: the priority alone decides [InlineData("Alarm.Set", "100", "1", 1000)] // vd03: TableAlarms_*.CommunicationAlarm, priority 100 / severity 1 public void Aveva_severity_zero_marks_an_event_that_is_not_an_alarm(string type, string priority, string? avevaSeverity, int expected) { var e = new HistorianEvent { Id = "S", Type = type, SourceName = "Oven_007", EventTime = Ts(2026, 11, 10, 11, 32, 35), ReceivedTime = Ts(2026, 11, 10, 11, 32, 35) }; e.Properties["priority"] = priority; if (avevaSeverity is not null) e.Properties["severity"] = avevaSeverity; Assert.Equal((ushort)expected, EventMapper.ToHistoricalEvent(e).Severity); } // Ts(...) builds a Google.Protobuf.WellKnownTypes.Timestamp from UTC parts. private static Timestamp Ts(int y, int mo, int d, int h, int mi, int s) => Timestamp.FromDateTime(new DateTime(y, mo, d, h, mi, s, DateTimeKind.Utc)); }