using Opc.Ua; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests; [Trait("Category", "Unit")] public sealed class OpcUaClientHistoryTests { /// Verifies MapAggregateToNodeId returns standard Part 13 aggregate for every enum. /// The history aggregate type to test. [Theory] [InlineData(HistoryAggregateType.Average)] [InlineData(HistoryAggregateType.Minimum)] [InlineData(HistoryAggregateType.Maximum)] [InlineData(HistoryAggregateType.Total)] [InlineData(HistoryAggregateType.Count)] public void MapAggregateToNodeId_returns_standard_Part13_aggregate_for_every_enum(HistoryAggregateType agg) { var nodeId = OpcUaClientDriver.MapAggregateToNodeId(agg); NodeId.IsNull(nodeId).ShouldBeFalse(); // Every mapping should resolve to an AggregateFunction_* NodeId (namespace 0, numeric id). nodeId.NamespaceIndex.ShouldBe((ushort)0); } /// Verifies MapAggregateToNodeId rejects invalid enum values. [Fact] public void MapAggregateToNodeId_rejects_invalid_enum_value() { // Defense-in-depth: a future HistoryAggregateType addition mustn't silently fall through. Should.Throw(() => OpcUaClientDriver.MapAggregateToNodeId((HistoryAggregateType)99)); } /// Verifies ReadRawAsync throws without initialization. [Fact] public async Task ReadRawAsync_without_initialize_throws_InvalidOperationException() { using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-hist-uninit"); await Should.ThrowAsync(async () => await drv.ReadRawAsync("ns=2;s=Counter", DateTime.UtcNow.AddMinutes(-5), DateTime.UtcNow, 1000, TestContext.Current.CancellationToken)); } /// Verifies ReadRawAsync with malformed NodeId returns empty result. [Fact] public async Task ReadRawAsync_with_malformed_NodeId_returns_empty_result_not_throw() { // Same defensive pattern as ReadAsync / WriteAsync — malformed NodeId short-circuits // to an empty result rather than crashing a batch history call. Needs init via the // throw path first, then we pass "" to trigger the parse-fail branch inside // ExecuteHistoryReadAsync. The init itself fails against 127.0.0.1:1 so we stop there. // Not runnable without init — keep as placeholder for when the in-process fixture // PR lands. await Task.CompletedTask; } /// Verifies ReadProcessedAsync throws without initialization. [Fact] public async Task ReadProcessedAsync_without_initialize_throws_InvalidOperationException() { using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-hist-uninit"); await Should.ThrowAsync(async () => await drv.ReadProcessedAsync("ns=2;s=Counter", DateTime.UtcNow.AddMinutes(-5), DateTime.UtcNow, TimeSpan.FromSeconds(10), HistoryAggregateType.Average, TestContext.Current.CancellationToken)); } /// Verifies ReadAtTimeAsync throws without initialization. [Fact] public async Task ReadAtTimeAsync_without_initialize_throws_InvalidOperationException() { using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-hist-uninit"); await Should.ThrowAsync(async () => await drv.ReadAtTimeAsync("ns=2;s=Counter", [DateTime.UtcNow.AddMinutes(-5), DateTime.UtcNow], TestContext.Current.CancellationToken)); } /// ReadEventsAsync requires an initialized session like the sibling history reads. [Fact] public async Task ReadEventsAsync_without_initialize_throws_InvalidOperationException() { using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-events-uninit"); await Should.ThrowAsync(async () => await drv.ReadEventsAsync( sourceName: null, startUtc: DateTime.UtcNow.AddMinutes(-5), endUtc: DateTime.UtcNow, maxEvents: 100, cancellationToken: TestContext.Current.CancellationToken)); } /// BuildBaseEventFilter emits the six canonical BaseEventType select clauses in order. [Fact] public void BuildBaseEventFilter_has_six_canonical_BaseEventType_value_clauses() { var filter = OpcUaClientDriver.BuildBaseEventFilter(); filter.SelectClauses.Count.ShouldBe(6); string[] expected = ["EventId", "SourceName", "Time", "ReceiveTime", "Message", "Severity"]; for (var i = 0; i < expected.Length; i++) { var clause = filter.SelectClauses[i]; clause.TypeDefinitionId.ShouldBe(ObjectTypeIds.BaseEventType); clause.AttributeId.ShouldBe(Attributes.Value); clause.BrowsePath.Count.ShouldBe(1); clause.BrowsePath[0].Name.ShouldBe(expected[i]); clause.BrowsePath[0].NamespaceIndex.ShouldBe((ushort)0); // BaseEventType fields live in ns 0 } } /// MapHistoryEvents maps every field by its canonical select-clause index. [Fact] public void MapHistoryEvents_maps_all_six_fields_by_canonical_index() { var eventTime = new DateTime(2026, 6, 18, 10, 0, 0, DateTimeKind.Utc); var recvTime = eventTime.AddSeconds(1); var he = new HistoryEvent(); var fields = new HistoryEventFieldList(); fields.EventFields.Add(new Variant(new byte[] { 1, 2, 3 })); // 0 EventId fields.EventFields.Add(new Variant("Pump17")); // 1 SourceName fields.EventFields.Add(new Variant(eventTime)); // 2 Time fields.EventFields.Add(new Variant(recvTime)); // 3 ReceiveTime fields.EventFields.Add(new Variant(new LocalizedText("High temp"))); // 4 Message fields.EventFields.Add(new Variant((ushort)700)); // 5 Severity he.Events.Add(fields); var mapped = OpcUaClientDriver.MapHistoryEvents(he); mapped.Count.ShouldBe(1); var e = mapped[0]; e.EventId.ShouldBe(Convert.ToBase64String(new byte[] { 1, 2, 3 })); e.SourceName.ShouldBe("Pump17"); e.EventTimeUtc.ShouldBe(eventTime); e.ReceivedTimeUtc.ShouldBe(recvTime); e.Message.ShouldBe("High temp"); e.Severity.ShouldBe((ushort)700); } /// MapHistoryEvents returns empty for a HistoryEvent with no rows. [Fact] public void MapHistoryEvents_with_no_events_returns_empty() { OpcUaClientDriver.MapHistoryEvents(new HistoryEvent()).ShouldBeEmpty(); } /// MapHistoryEvents tolerates a field list shorter than six without throwing. [Fact] public void MapHistoryEvents_tolerates_short_field_list_without_throwing() { var he = new HistoryEvent(); var fields = new HistoryEventFieldList(); fields.EventFields.Add(new Variant(new byte[] { 9 })); // only EventId present he.Events.Add(fields); var mapped = OpcUaClientDriver.MapHistoryEvents(he); mapped.Count.ShouldBe(1); mapped[0].EventId.ShouldBe(Convert.ToBase64String(new byte[] { 9 })); mapped[0].SourceName.ShouldBeNull(); mapped[0].EventTimeUtc.ShouldBe(DateTime.MinValue); mapped[0].EventTimeUtc.Kind.ShouldBe(DateTimeKind.Utc); // sentinel is UTC-kinded, not Unspecified mapped[0].Severity.ShouldBe((ushort)0); } }