Auto: opcuaclient-12 — IHistoryProvider.ReadEventsAsync EventFilter spec + impl
Adds a filter-aware overload of IHistoryProvider.ReadEventsAsync that carries EventFilter SelectClauses + WhereClause, and implements it on the OPC UA Client driver via Session.HistoryReadAsync + ReadEventDetails. The change is additive (default-impl returns NotSupportedException) so the existing Galaxy.Proxy.GalaxyProxyDriver implementation keeps compiling against the fixed-field overload — no cross-driver refactor required. * Core.Abstractions: new EventHistoryRequest / SimpleAttributeSpec / ContentFilterSpec records mirror the OPC UA wire shape transport-neutrally. HistoricalEventBatch / HistoricalEventRow carry an open-ended Fields bag keyed by SimpleAttributeSpec.FieldName so server-side dispatch can re-align with the client's wire-side SelectClause order. * OpcUaClient driver: new ReadEventsAsync(fullReference, EventHistoryRequest, ct) builds an EventFilter, calls Session.HistoryReadAsync, and unwraps HistoryEvent.Events into HistoricalEventBatch rows. Default SelectClause set matches BuildHistoryEvent on the server side. ContentFilter bytes are decoded through the live session's MessageContext (passthrough — the driver does not evaluate filters). * Unit tests: 7 new tests cover SelectClause translation, default-clause fallback, malformed where-clause swallowing, uninitialized-driver guard, null-request guard, and IHistoryProvider default fallback. * Integration scaffold: build-only [Fact] gated on opc-plc --alm; flips to green when the fixture image is upgraded. * Docs: HistoryRead Events section in docs/drivers/OpcUaClient.md plus a cross-link from Client.CLI.md historyread page. * E2E: -HistoryEvents switch on scripts/e2e/test-opcuaclient.ps1 confirms the gateway round-trips HistoryReadEvents without BadHistoryOperationUnsupported (gated; defaults to skip). Closes #284
This commit is contained in:
@@ -0,0 +1,64 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// End-to-end smoke against a live <c>opc-plc</c> simulator launched in alarm mode
|
||||
/// (<c>--alm</c>). Exercises the filter-aware
|
||||
/// <see cref="OpcUaClientDriver.ReadEventsAsync(string, EventHistoryRequest, System.Threading.CancellationToken)"/>
|
||||
/// overload by issuing a HistoryReadEvents against the simulator's <c>Server</c> notifier
|
||||
/// and asserting at least one historical event row comes back with the SelectClause
|
||||
/// fields populated.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Requires the simulator started with <c>--alm</c> (alarm + history simulation), which
|
||||
/// <see cref="OpcPlcFixture"/> does not guarantee — the test skips with a clear reason
|
||||
/// when the upstream returns <c>BadHistoryOperationUnsupported</c> instead of a HistoryEvent
|
||||
/// payload. PR-12 ships the build-only scaffold; the green-test pass lands when the
|
||||
/// fixture image is upgraded to the alarm SKU.
|
||||
/// </remarks>
|
||||
[Collection(OpcPlcCollection.Name)]
|
||||
[Trait("Category", "Integration")]
|
||||
[Trait("Simulator", "opc-plc")]
|
||||
public sealed class OpcUaClientHistoryEventsTests(OpcPlcFixture sim)
|
||||
{
|
||||
[Fact]
|
||||
public async Task ReadEventsAsync_against_opc_plc_alarm_mode_returns_BaseEventType_fields()
|
||||
{
|
||||
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
|
||||
|
||||
// Build-only scaffold: the test wires up the call but skips before assertions until
|
||||
// the opc-plc fixture is launched with --alm. When the fixture image carries alarms
|
||||
// the call should round-trip through Session.HistoryReadAsync + ReadEventDetails and
|
||||
// produce at least one HistoricalEventRow with the default SelectClause keys
|
||||
// populated (EventId / SourceName / Time / Message / Severity / ReceiveTime).
|
||||
Assert.Skip(
|
||||
"opc-plc --alm mode not guaranteed by the default fixture image. " +
|
||||
"Re-enable when OpcPlcFixture is upgraded to launch with --alm and a known-good " +
|
||||
"alarm event source path.");
|
||||
|
||||
#pragma warning disable CS0162 // unreachable scaffold below — kept for the post-fixture-upgrade flip
|
||||
var options = OpcPlcProfile.BuildOptions(sim.EndpointUrl);
|
||||
await using var drv = new OpcUaClientDriver(options, driverInstanceId: "opcua-events-smoke");
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
var request = new EventHistoryRequest(
|
||||
StartTime: DateTime.UtcNow.AddMinutes(-30),
|
||||
EndTime: DateTime.UtcNow.AddMinutes(1),
|
||||
NumValuesPerNode: 100,
|
||||
SelectClauses: null,
|
||||
WhereClause: null);
|
||||
|
||||
// The Server node (i=2253) is the standard history-events notifier on opc-plc.
|
||||
var batch = await drv.ReadEventsAsync("i=2253", request, TestContext.Current.CancellationToken);
|
||||
|
||||
batch.ShouldNotBeNull();
|
||||
batch.Events.Count.ShouldBeGreaterThan(0, "opc-plc --alm raises events at least every 5s");
|
||||
var first = batch.Events[0];
|
||||
first.Fields.ShouldContainKey("EventId");
|
||||
first.Fields.ShouldContainKey("Severity");
|
||||
#pragma warning restore CS0162
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
using Opc.Ua;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the filter-aware
|
||||
/// <see cref="OpcUaClientDriver.ReadEventsAsync(string, EventHistoryRequest, System.Threading.CancellationToken)"/>
|
||||
/// overload (PR-12 / #284). The driver-level wire path needs a live <see cref="ISession"/>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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<SimpleAttributeSpec>
|
||||
{
|
||||
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<SimpleAttributeSpec>
|
||||
{
|
||||
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<SimpleAttributeSpec>
|
||||
{
|
||||
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<InvalidOperationException>(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<ArgumentNullException>(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<NotSupportedException>(async () =>
|
||||
await stub.ReadEventsAsync(
|
||||
fullReference: "ns=2;s=AlarmsNotifier",
|
||||
request: request,
|
||||
cancellationToken: TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
private sealed class NotImplementedHistoryStub : IHistoryProvider
|
||||
{
|
||||
public Task<Core.Abstractions.HistoryReadResult> ReadRawAsync(string fullReference, DateTime startUtc, DateTime endUtc,
|
||||
uint maxValuesPerNode, CancellationToken cancellationToken)
|
||||
=> throw new NotImplementedException();
|
||||
|
||||
public Task<Core.Abstractions.HistoryReadResult> ReadProcessedAsync(string fullReference, DateTime startUtc, DateTime endUtc,
|
||||
TimeSpan interval, HistoryAggregateType aggregate, CancellationToken cancellationToken)
|
||||
=> throw new NotImplementedException();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user