170 lines
7.8 KiB
C#
170 lines
7.8 KiB
C#
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
|
|
{
|
|
/// <summary>Verifies MapAggregateToNodeId returns standard Part 13 aggregate for every enum.</summary>
|
|
/// <param name="agg">The history aggregate type to test.</param>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>Verifies MapAggregateToNodeId rejects invalid enum values.</summary>
|
|
[Fact]
|
|
public void MapAggregateToNodeId_rejects_invalid_enum_value()
|
|
{
|
|
// Defense-in-depth: a future HistoryAggregateType addition mustn't silently fall through.
|
|
Should.Throw<ArgumentOutOfRangeException>(() =>
|
|
OpcUaClientDriver.MapAggregateToNodeId((HistoryAggregateType)99));
|
|
}
|
|
|
|
/// <summary>Verifies ReadRawAsync throws without initialization.</summary>
|
|
[Fact]
|
|
public async Task ReadRawAsync_without_initialize_throws_InvalidOperationException()
|
|
{
|
|
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-hist-uninit");
|
|
await Should.ThrowAsync<InvalidOperationException>(async () =>
|
|
await drv.ReadRawAsync("ns=2;s=Counter",
|
|
DateTime.UtcNow.AddMinutes(-5), DateTime.UtcNow, 1000,
|
|
TestContext.Current.CancellationToken));
|
|
}
|
|
|
|
/// <summary>Verifies ReadRawAsync with malformed NodeId returns empty result.</summary>
|
|
[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;
|
|
}
|
|
|
|
/// <summary>Verifies ReadProcessedAsync throws without initialization.</summary>
|
|
[Fact]
|
|
public async Task ReadProcessedAsync_without_initialize_throws_InvalidOperationException()
|
|
{
|
|
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-hist-uninit");
|
|
await Should.ThrowAsync<InvalidOperationException>(async () =>
|
|
await drv.ReadProcessedAsync("ns=2;s=Counter",
|
|
DateTime.UtcNow.AddMinutes(-5), DateTime.UtcNow,
|
|
TimeSpan.FromSeconds(10), HistoryAggregateType.Average,
|
|
TestContext.Current.CancellationToken));
|
|
}
|
|
|
|
/// <summary>Verifies ReadAtTimeAsync throws without initialization.</summary>
|
|
[Fact]
|
|
public async Task ReadAtTimeAsync_without_initialize_throws_InvalidOperationException()
|
|
{
|
|
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-hist-uninit");
|
|
await Should.ThrowAsync<InvalidOperationException>(async () =>
|
|
await drv.ReadAtTimeAsync("ns=2;s=Counter",
|
|
[DateTime.UtcNow.AddMinutes(-5), DateTime.UtcNow],
|
|
TestContext.Current.CancellationToken));
|
|
}
|
|
|
|
/// <summary>ReadEventsAsync requires an initialized session like the sibling history reads.</summary>
|
|
[Fact]
|
|
public async Task ReadEventsAsync_without_initialize_throws_InvalidOperationException()
|
|
{
|
|
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-events-uninit");
|
|
await Should.ThrowAsync<InvalidOperationException>(async () =>
|
|
await drv.ReadEventsAsync(
|
|
sourceName: null,
|
|
startUtc: DateTime.UtcNow.AddMinutes(-5),
|
|
endUtc: DateTime.UtcNow,
|
|
maxEvents: 100,
|
|
cancellationToken: TestContext.Current.CancellationToken));
|
|
}
|
|
|
|
/// <summary>BuildBaseEventFilter emits the six canonical BaseEventType select clauses in order.</summary>
|
|
[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
|
|
}
|
|
}
|
|
|
|
/// <summary>MapHistoryEvents maps every field by its canonical select-clause index.</summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>MapHistoryEvents returns empty for a HistoryEvent with no rows.</summary>
|
|
[Fact]
|
|
public void MapHistoryEvents_with_no_events_returns_empty()
|
|
{
|
|
OpcUaClientDriver.MapHistoryEvents(new HistoryEvent()).ShouldBeEmpty();
|
|
}
|
|
|
|
/// <summary>MapHistoryEvents tolerates a field list shorter than six without throwing.</summary>
|
|
[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);
|
|
}
|
|
}
|