using Microsoft.Extensions.Logging.Abstractions; using Opc.Ua; using Opc.Ua.Server; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using HistorianRead = ZB.MOM.WW.OtOpcUa.Core.Abstractions.HistoryReadResult; using SdkHistoryReadResult = Opc.Ua.HistoryReadResult; namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests; /// /// HistoryReadEvents projects every field a historical alarm event can supply — EventType (the stored alarm /// type name mapped to its Part 9 type, an unmodelled one to AlarmConditionType), ConditionName, and /// SourceNode when the event's alarm id resolves to a materialised condition — and honours the request's /// where-clause (an OfType by the type tree; any other operator against the projected fields). These used to /// project and the where-clause was ignored. /// public sealed class NodeManagerHistoryReadEventsProjectionTests : IDisposable { private static CancellationToken Ct => TestContext.Current.CancellationToken; private static readonly DateTime T0 = new(2026, 10, 22, 8, 0, 0, DateTimeKind.Utc); private readonly string _pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-evtproj-{Guid.NewGuid():N}"); private const string Equipment = "eq-proj"; private static readonly HistoricalEvent[] Events = [ new("e-limit", Equipment, T0, T0, "hi", 700, EventTypeName: "LimitAlarm", ConditionName: "HighTemp", AlarmId: "alarm-1"), new("e-offnormal", Equipment, T0.AddSeconds(1), T0, "off", 500, EventTypeName: "OffNormalAlarm", ConditionName: "Door", AlarmId: "alarm-gone"), new("e-untyped", Equipment, T0.AddSeconds(2), T0, "x", 300), new("e-bogus", Equipment, T0.AddSeconds(3), T0, "y", 900, EventTypeName: "Folder", ConditionName: "Odd"), ]; [Fact] public async Task EventType_ConditionName_and_a_resolvable_SourceNode_are_projected() { var (host, server, nm, notifier) = await BootAsync(); var condition = nm.TryGetAlarmCondition("alarm-1")!.NodeId; var history = Read(server, nm, notifier, Select("EventId", "EventType", "ConditionName", "SourceNode"), where: null); var rows = history.Events.ToDictionary(e => System.Text.Encoding.UTF8.GetString((byte[])e.EventFields[0].Value)); rows["e-limit"].EventFields[1].Value.ShouldBe(ObjectTypeIds.LimitAlarmType); rows["e-limit"].EventFields[2].Value.ShouldBe("HighTemp"); rows["e-limit"].EventFields[3].Value.ShouldBe(condition, "the alarm id resolves to the materialised condition — the live events' SourceNode"); rows["e-offnormal"].EventFields[1].Value.ShouldBe(ObjectTypeIds.OffNormalAlarmType); rows["e-offnormal"].EventFields[3].Value.ShouldBeNull("no condition is materialised for that alarm id"); // No stored type: still an alarm condition (every historical row is alarm history); an unrecognised or non-event // type name is not passed through as a non-event type. rows["e-untyped"].EventFields[1].Value.ShouldBe(ObjectTypeIds.AlarmConditionType); rows["e-untyped"].EventFields[2].Value.ShouldBeNull(); rows["e-bogus"].EventFields[1].Value.ShouldBe(ObjectTypeIds.AlarmConditionType); await host.DisposeAsync(); } [Fact] public async Task An_OfType_where_clause_keeps_only_events_of_that_type_or_a_subtype() { var (host, server, nm, notifier) = await BootAsync(); Ids(Read(server, nm, notifier, Select("EventId"), OfType(ObjectTypeIds.LimitAlarmType))).ShouldBe(["e-limit"]); Ids(Read(server, nm, notifier, Select("EventId"), OfType(ObjectTypeIds.OffNormalAlarmType))).ShouldBe(["e-offnormal"]); // A supertype keeps its subtypes: every row is an AlarmConditionType (positive control — the filter is not // simply dropping everything typed). Ids(Read(server, nm, notifier, Select("EventId"), OfType(ObjectTypeIds.AlarmConditionType))) .ShouldBe(["e-limit", "e-offnormal", "e-untyped", "e-bogus"]); // An unrelated event type keeps nothing (GoodNoData). Ids(Read(server, nm, notifier, Select("EventId"), OfType(ObjectTypeIds.AuditEventType))).ShouldBeEmpty(); await host.DisposeAsync(); } [Fact] public async Task A_field_where_clause_is_evaluated_against_the_projected_fields() { var (host, server, nm, notifier) = await BootAsync(); var where = new ContentFilter(); where.Push(FilterOperator.GreaterThan, new SimpleAttributeOperand(ObjectTypeIds.BaseEventType, new QualifiedName("Severity")) { AttributeId = Attributes.Value }, new LiteralOperand(new Variant((ushort)600))); Ids(Read(server, nm, notifier, Select("EventId"), where)).ShouldBe(["e-limit", "e-bogus"]); await host.DisposeAsync(); } private static string[] Ids(HistoryEvent h) => h.Events.Select(e => System.Text.Encoding.UTF8.GetString((byte[])e.EventFields[0].Value)).ToArray(); private static ContentFilter OfType(NodeId type) { var f = new ContentFilter(); f.Push(FilterOperator.OfType, new LiteralOperand(new Variant(type))); return f; } private static SimpleAttributeOperandCollection Select(params string[] leaves) { var c = new SimpleAttributeOperandCollection(); foreach (var leaf in leaves) c.Add(new SimpleAttributeOperand(ObjectTypeIds.BaseEventType, new QualifiedName(leaf)) { AttributeId = Attributes.Value }); return c; } private static HistoryEvent Read(OtOpcUaSdkServer server, OtOpcUaNodeManager nm, NodeId notifier, SimpleAttributeOperandCollection select, ContentFilter? where) { var details = new ReadEventDetails { StartTime = T0.AddHours(-1), EndTime = T0.AddHours(1), NumValuesPerNode = 100, Filter = new EventFilter { SelectClauses = select, WhereClause = where ?? new ContentFilter() }, }; var context = new OperationContext(new RequestHeader(), secureChannelContext: null, RequestType.HistoryRead, identity: null); var results = new List { null! }; var errors = new List { null! }; nm.HistoryRead(context, details, TimestampsToReturn.Both, releaseContinuationPoints: false, new List { new() { NodeId = notifier } }, results, errors); StatusCode.IsGood(errors[0].StatusCode).ShouldBeTrue($"HistoryRead failed: {errors[0].StatusCode}"); return (HistoryEvent)ExtensionObject.ToEncodeable(results[0].HistoryData); } private async Task<(OpcUaApplicationHost Host, OtOpcUaSdkServer Server, OtOpcUaNodeManager Nm, NodeId Notifier)> BootAsync() { var started = await OpcUaHostStart.StartAsync( port => new OpcUaApplicationHost( new OpcUaApplicationHostOptions { ApplicationName = "OtOpcUa.HistoryReadEventsProjectionTest", ApplicationUri = $"urn:OtOpcUa.HistoryReadEventsProjectionTest:{Guid.NewGuid():N}", OpcUaPort = port, PublicHostname = "localhost", PkiStoreRoot = _pkiRoot, }, NullLogger.Instance), () => new OtOpcUaSdkServer(), Ct); var nm = started.Server.NodeManager!; nm.HistorianDataSource = new FixedEventsSource(Events); nm.EnsureFolder(Equipment, parentNodeId: null, displayName: "Equipment", realm: AddressSpaceRealm.Uns); nm.MaterialiseAlarmCondition("alarm-1", Equipment, "HighTemp", "LimitAlarm", severity: 700, realm: AddressSpaceRealm.Uns); return (started.Host, started.Server, nm, nm.TryGetFolder(Equipment)!.NodeId); } private sealed class FixedEventsSource(IReadOnlyList events) : IHistorianDataSource { public Task ReadRawAsync(string fullReference, DateTime startUtc, DateTime endUtc, uint maxValuesPerNode, CancellationToken cancellationToken) => NullHistorianDataSource.Instance.ReadRawAsync(fullReference, startUtc, endUtc, maxValuesPerNode, cancellationToken); public Task ReadProcessedAsync(string fullReference, DateTime startUtc, DateTime endUtc, TimeSpan interval, HistoryAggregateType aggregate, CancellationToken cancellationToken) => NullHistorianDataSource.Instance.ReadProcessedAsync(fullReference, startUtc, endUtc, interval, aggregate, cancellationToken); public Task ReadAtTimeAsync(string fullReference, IReadOnlyList timestampsUtc, CancellationToken cancellationToken) => NullHistorianDataSource.Instance.ReadAtTimeAsync(fullReference, timestampsUtc, cancellationToken); public Task ReadEventsAsync(string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents, CancellationToken cancellationToken) => Task.FromResult(new HistoricalEventsResult(events.Where(e => e.EventTimeUtc >= startUtc && e.EventTimeUtc < endUtc).ToList(), null)); public HistorianHealthSnapshot GetHealthSnapshot() => NullHistorianDataSource.Instance.GetHealthSnapshot(); public void Dispose() { } } public void Dispose() { if (Directory.Exists(_pkiRoot)) { try { Directory.Delete(_pkiRoot, recursive: true); } catch { /* best-effort cleanup */ } } } }