using Opc.Ua; using Opc.Ua.Client; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Client.Shared; using ZB.MOM.WW.OtOpcUa.Client.Shared.Models; using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using CoreHistoryReadResult = ZB.MOM.WW.OtOpcUa.Core.Abstractions.HistoryReadResult; using static ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests.ServerEventRig; namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests; /// /// Over a real SDK client session: a HistoryReadEvents whose select clause includes EventType, /// ConditionName and SourceNode — what a generic alarm viewer asks for — gets non-null values (they all /// used to be null), and an OfType where-clause is honoured on the wire. /// public sealed class EventHistoryProjectionOverTheWireTests { private static readonly DateTime Epoch = new(2026, 10, 22, 0, 0, 0, DateTimeKind.Utc); [Fact] public async Task EventType_ConditionName_and_SourceNode_come_back_non_null_and_OfType_filters() { var ct = TestContext.Current.CancellationToken; var rig = await StartAsync("EvtProjectionWire", encrypted: false, ct); try { rig.Nm.HistorianDataSource = new FixedSource( [ new HistoricalEvent("e-limit", RawDeviceFolder, Epoch, Epoch, "high", 700, EventTypeName: "LimitAlarm", ConditionName: "temp_hi", AlarmId: RawAlarmPath), new HistoricalEvent("e-off", RawDeviceFolder, Epoch.AddSeconds(1), Epoch, "door", 500, EventTypeName: "OffNormalAlarm", ConditionName: "door_open", AlarmId: RawAlarmPath), ]); rig.MaterialiseNative(); rig.Enforce(false, [Key()]); using var session = await rig.ConnectAsync(anonymous: true, ct); var node = new NodeId(RawDeviceFolder, (ushort)session.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri)); var all = await ReadAsync(session, node, where: null, ct); all.Count.ShouldBe(2); foreach (var fields in all) { fields[1].Value.ShouldNotBeNull("EventType"); fields[2].Value.ShouldNotBeNull("ConditionName"); fields[3].Value.ShouldNotBeNull("SourceNode"); } all[0][1].Value.ShouldBe(ObjectTypeIds.LimitAlarmType); all[0][2].Value.ShouldBe("temp_hi"); all[0][3].Value.ShouldBe(ConditionNodeId(session, native: true), "the native condition the alarm id names"); var where = new ContentFilter(); where.Push(FilterOperator.OfType, new LiteralOperand(new Variant(ObjectTypeIds.LimitAlarmType))); var limits = await ReadAsync(session, node, where, ct); limits.Select(f => System.Text.Encoding.UTF8.GetString((byte[])f[0].Value)).ShouldBe(["e-limit"]); } finally { await rig.DisposeAsync(); } } /// /// #776: the shared client's event-history read — the code behind Client.CLI's historyread --events — over /// the wire with the real session adapter: the default CLI select clauses come back non-null, --of-type /// filters, and an invalid where-clause is refused as BadEventFilterInvalid (#778), not printed as no events. /// [Fact] public async Task The_shared_client_reads_event_history_with_projection_and_OfType() { var ct = TestContext.Current.CancellationToken; var rig = await StartAsync("EvtHistoryClient", encrypted: false, ct); try { rig.Nm.HistorianDataSource = new FixedSource( [ new HistoricalEvent("e-limit", RawDeviceFolder, Epoch, Epoch, "high", 700, EventTypeName: "LimitAlarm", ConditionName: "temp_hi", AlarmId: RawAlarmPath), new HistoricalEvent("e-off", RawDeviceFolder, Epoch.AddSeconds(1), Epoch, "door", 500, EventTypeName: "OffNormalAlarm", ConditionName: "door_open", AlarmId: RawAlarmPath), ]); rig.MaterialiseNative(); rig.Enforce(false, [Key()]); using var client = new OpcUaClientService(); await client.ConnectAsync(new ConnectionSettings { EndpointUrl = rig.EndpointUrl, CertificateStorePath = Path.Combine(Path.GetTempPath(), $"otopcua-clientpki-{Guid.NewGuid():N}"), }, ct); ushort rawNs; using (var session = await rig.ConnectAsync(anonymous: true, ct)) rawNs = (ushort)session.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri); var node = new NodeId(RawDeviceFolder, rawNs); // The CLI's default fields: EventId, EventType, Time, SourceName, Severity, Message, ConditionName, SourceNode. var all = await client.HistoryReadEventsAsync(node, Epoch.AddDays(-1), Epoch.AddDays(1), Filter(ofType: null), 100, ct); all.Count.ShouldBe(2); foreach (var e in all) e.Fields.ShouldAllBe(f => f.Value != null, "every default field is projected"); all[0].Fields[1].Value.ShouldBe(ObjectTypeIds.LimitAlarmType); all[0].Fields[4].Value.ShouldBe((ushort)700); all[0].Fields[6].Value.ShouldBe("temp_hi"); var limits = await client.HistoryReadEventsAsync(node, Epoch.AddDays(-1), Epoch.AddDays(1), Filter(ObjectTypeIds.LimitAlarmType), 100, ct); limits.Select(e => System.Text.Encoding.UTF8.GetString((byte[])e.Fields[0].Value)).ShouldBe(["e-limit"]); var invalid = Filter(ofType: null); invalid.WhereClause.Push(FilterOperator.Equals, new LiteralOperand(new Variant(1))); var refused = await Should.ThrowAsync(() => client.HistoryReadEventsAsync(node, Epoch.AddDays(-1), Epoch.AddDays(1), invalid, 100, ct)); refused.StatusCode.ShouldBe(StatusCodes.BadEventFilterInvalid); } finally { await rig.DisposeAsync(); } static EventFilter Filter(NodeId? ofType) { var filter = new EventFilter(); foreach (var leaf in new[] { "EventId", "EventType", "Time", "SourceName", "Severity", "Message", "ConditionName", "SourceNode" }) { var type = leaf == "ConditionName" ? ObjectTypeIds.ConditionType : ObjectTypeIds.BaseEventType; filter.SelectClauses.Add(new SimpleAttributeOperand(type, new QualifiedName(leaf)) { AttributeId = Attributes.Value }); } if (ofType is not null) filter.WhereClause.Push(FilterOperator.OfType, new LiteralOperand(new Variant(ofType))); return filter; } } private static async Task> ReadAsync(ISession session, NodeId node, ContentFilter? where, CancellationToken ct) { var filter = new EventFilter { WhereClause = where ?? new ContentFilter() }; foreach (var leaf in new[] { "EventId", "EventType", "ConditionName", "SourceNode" }) { var type = leaf == "ConditionName" ? ObjectTypeIds.ConditionType : ObjectTypeIds.BaseEventType; filter.SelectClauses.Add(new SimpleAttributeOperand(type, new QualifiedName(leaf)) { AttributeId = Attributes.Value }); } var details = new ReadEventDetails { StartTime = Epoch.AddDays(-1), EndTime = Epoch.AddDays(1), NumValuesPerNode = 100, Filter = filter, }; var response = await session.HistoryReadAsync( null, new ExtensionObject(details), TimestampsToReturn.Both, false, new HistoryReadValueIdCollection { new HistoryReadValueId { NodeId = node } }, ct); var result = response.Results.ShouldHaveSingleItem(); StatusCode.IsBad(result.StatusCode).ShouldBeFalse($"HistoryRead failed: {result.StatusCode}"); var he = (HistoryEvent)ExtensionObject.ToEncodeable(result.HistoryData); return he.Events.Select(e => e.EventFields).ToList(); } private sealed class FixedSource(IReadOnlyList events) : IHistorianDataSource { public Task ReadEventsAsync(string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents, CancellationToken ct) => Task.FromResult(new HistoricalEventsResult(events.Where(e => e.EventTimeUtc >= startUtc && e.EventTimeUtc < endUtc).ToList(), null)); 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 HistorianHealthSnapshot GetHealthSnapshot() => NullHistorianDataSource.Instance.GetHealthSnapshot(); public void Dispose() { } } }