using Opc.Ua;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
///
/// Unit tests for the filter-aware
///
/// overload (PR-12 / #284). The driver-level wire path needs a live
/// so the round-trip-through-Session.HistoryReadAsync test lands as an integration test;
/// here we cover the surface that's reachable without a session: SelectClause translation,
/// default-clause fallback, and the EventFilter projection helper.
///
[Trait("Category", "Unit")]
public sealed class OpcUaClientHistoryEventsTests
{
[Fact]
public void DefaultEventSelectClauses_carries_the_standard_BaseEventType_columns()
{
// The fallback set must match BuildHistoryEvent on the server side so a client that
// doesn't customize the EventFilter still sees recognizable BaseEventType columns
// (EventId, SourceName, Time, Message, Severity, ReceiveTime).
var defaults = OpcUaClientDriver.DefaultEventSelectClauses;
defaults.Count.ShouldBe(6);
defaults.Select(d => d.FieldName).ShouldBe(
["EventId", "SourceName", "Time", "Message", "Severity", "ReceiveTime"]);
// None of the defaults reach into a typed path — they're all rooted at BaseEventType
// (TypeDefinitionId=null sentinel).
defaults.All(d => d.TypeDefinitionId is null).ShouldBeTrue();
defaults.All(d => d.BrowsePath.Count == 1).ShouldBeTrue();
}
[Fact]
public void ToOpcEventFilter_translates_each_SimpleAttributeSpec_to_a_SimpleAttributeOperand()
{
var clauses = new List
{
new(null, ["EventId"], "EventId"),
new(null, ["Severity"], "Severity"),
new("i=2782" /* ConditionType */, [], "ConditionId"),
};
var filter = OpcUaClientDriver.ToOpcEventFilter(clauses, whereClause: null);
filter.SelectClauses.Count.ShouldBe(3);
filter.SelectClauses[0].TypeDefinitionId.ShouldBe(ObjectTypeIds.BaseEventType);
filter.SelectClauses[0].BrowsePath.Count.ShouldBe(1);
filter.SelectClauses[0].BrowsePath[0].Name.ShouldBe("EventId");
filter.SelectClauses[0].AttributeId.ShouldBe(Attributes.Value);
filter.SelectClauses[1].BrowsePath[0].Name.ShouldBe("Severity");
// Typed-path entry: TypeDefinitionId parses to the supplied NodeId text and
// BrowsePath stays empty (= "the typed node itself").
filter.SelectClauses[2].TypeDefinitionId.ShouldBe(NodeId.Parse("i=2782"));
filter.SelectClauses[2].BrowsePath.Count.ShouldBe(0);
}
[Fact]
public void ToOpcEventFilter_with_null_where_clause_leaves_WhereClause_empty()
{
// Empty WhereClause is the OPC UA equivalent of "no filter" — every event matches.
// The client driver only attaches a WhereClause when one was decoded successfully;
// a null/empty ContentFilterSpec should never produce an Elements collection.
var clauses = new List
{
new(null, ["EventId"], "EventId"),
};
var filter = OpcUaClientDriver.ToOpcEventFilter(clauses, whereClause: null);
filter.WhereClause.ShouldNotBeNull();
filter.WhereClause.Elements.Count.ShouldBe(0);
}
[Fact]
public void ToOpcEventFilter_with_malformed_where_clause_bytes_swallows_and_yields_empty_filter()
{
// Defense-in-depth: a corrupt encoded filter must not throw out of the helper.
// The driver chooses to drop the where-clause silently rather than fail the whole
// HistoryReadEvents call (best-effort projection per IHistoryProvider contract).
var clauses = new List
{
new(null, ["EventId"], "EventId"),
};
var bogus = new ContentFilterSpec([0xFF, 0xFE, 0xFD]);
// Provide a real MessageContext so the BinaryDecoder path is exercised; without it
// the helper never attempts to decode and the test wouldn't cover the catch branch.
#pragma warning disable CS0618 // ServiceMessageContext() — telemetry-context overload is irrelevant for unit decode.
var ctx = new ServiceMessageContext();
#pragma warning restore CS0618
var filter = OpcUaClientDriver.ToOpcEventFilter(clauses, bogus, ctx);
filter.SelectClauses.Count.ShouldBe(1);
// Either the decoder produced a default ContentFilter (Elements=0) or the catch
// branch left the wire-default in place — either way no exception escaped.
filter.WhereClause.ShouldNotBeNull();
}
[Fact]
public async Task ReadEventsAsync_filter_overload_without_initialize_throws_InvalidOperationException()
{
// Same uninitialized-driver guard the rest of the IHistoryProvider methods use.
// Confirms the new overload is wired through RequireSession() rather than silently
// returning an empty batch on a never-connected driver (which would mask wiring bugs).
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-evt-uninit");
var request = new EventHistoryRequest(
StartTime: DateTime.UtcNow.AddMinutes(-5),
EndTime: DateTime.UtcNow,
NumValuesPerNode: 100,
SelectClauses: null,
WhereClause: null);
await Should.ThrowAsync(async () =>
await drv.ReadEventsAsync(
fullReference: "ns=2;s=AlarmsNotifier",
request: request,
cancellationToken: TestContext.Current.CancellationToken));
}
[Fact]
public async Task ReadEventsAsync_filter_overload_rejects_null_request()
{
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-evt-null");
await Should.ThrowAsync(async () =>
await drv.ReadEventsAsync(
fullReference: "ns=2;s=AlarmsNotifier",
request: null!,
cancellationToken: TestContext.Current.CancellationToken));
}
[Fact]
public async Task IHistoryProvider_filter_aware_default_throws_NotSupportedException_for_other_drivers()
{
// Other drivers that haven't opted in to the filter-aware overload must still see
// the IHistoryProvider default — same shape as the parameterless overload's default.
// We use a no-op stub to exercise the interface default's path.
IHistoryProvider stub = new NotImplementedHistoryStub();
var request = new EventHistoryRequest(
StartTime: DateTime.UtcNow.AddMinutes(-5),
EndTime: DateTime.UtcNow,
NumValuesPerNode: 100,
SelectClauses: null,
WhereClause: null);
await Should.ThrowAsync(async () =>
await stub.ReadEventsAsync(
fullReference: "ns=2;s=AlarmsNotifier",
request: request,
cancellationToken: TestContext.Current.CancellationToken));
}
private sealed class NotImplementedHistoryStub : IHistoryProvider
{
public Task ReadRawAsync(string fullReference, DateTime startUtc, DateTime endUtc,
uint maxValuesPerNode, CancellationToken cancellationToken)
=> throw new NotImplementedException();
public Task ReadProcessedAsync(string fullReference, DateTime startUtc, DateTime endUtc,
TimeSpan interval, HistoryAggregateType aggregate, CancellationToken cancellationToken)
=> throw new NotImplementedException();
}
}