Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadEventsProjectionTests.cs
T
Joseph Doherty 29665c1872 feat(opcua): HistoryReadEvents projects EventType, ConditionName, SourceNode; honours the where-clause (lmxopcua#746)
HistoryReadEvents projected only EventId / SourceName / Time / ReceiveTime /
Message / Severity: a select clause asking for EventType, ConditionName or
SourceNode (what most generic alarm viewers ask for) got nulls, and the
event filter's where-clause (an OfType) was ignored.

- HistoricalEvent gains optional EventTypeName, ConditionName and AlarmId.
  SqlServer, QuestDB and Gateway event reads fill them from what their
  alarm writers already store (AlarmTypeName / AlarmName / AlarmId; the
  gateway event Type and its AlarmName / AlarmId properties).
- The node manager maps the type name to its Part 9 event type (unknown or
  non-event => AlarmConditionType), projects ConditionName, and resolves
  SourceNode to the materialised condition the alarm id names (the live
  events' SourceNode), else null.
- The where-clause is evaluated per event with the SDK content filter
  through an IFilterTarget (OfType via the type tree; other operators
  against the projected fields), after the page's cursor is taken, so
  paging is unchanged.

Tested in the node manager, over a real client session (the Client.CLI has
no `historyread --events` mode yet; filed as a follow-up), and by alarm
writer -> event read round trips on SqlServer and QuestDB.

Rebased from the unpushed remed/lane-H2 branch (originally authored 2026-10-22
in session_01Ub9hGmYLQvn9XETzw7CMsT) onto current master.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 15:40:47 -05:00

180 lines
9.5 KiB
C#

using Microsoft.Extensions.Logging.Abstractions;
using Opc.Ua;
using Opc.Ua.Server;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using HistorianRead = ZB.MOM.WW.OtOpcUa.Core.Abstractions.HistoryReadResult;
using SdkHistoryReadResult = Opc.Ua.HistoryReadResult;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
/// <summary>
/// HistoryReadEvents projects every field a historical alarm event can supply — <c>EventType</c> (the stored alarm
/// type name mapped to its Part 9 type, an unmodelled one to <c>AlarmConditionType</c>), <c>ConditionName</c>, and
/// <c>SourceNode</c> when the event's alarm id resolves to a materialised condition — and honours the request's
/// where-clause (an <c>OfType</c> by the type tree; any other operator against the projected fields). These used to
/// project <see cref="Variant.Null"/> and the where-clause was ignored.
/// </summary>
public sealed class NodeManagerHistoryReadEventsProjectionTests : IDisposable
{
private static CancellationToken Ct => TestContext.Current.CancellationToken;
private static readonly DateTime T0 = new(2026, 10, 22, 8, 0, 0, DateTimeKind.Utc);
private readonly string _pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-evtproj-{Guid.NewGuid():N}");
private const string Equipment = "eq-proj";
private static readonly HistoricalEvent[] Events =
[
new("e-limit", Equipment, T0, T0, "hi", 700, EventTypeName: "LimitAlarm", ConditionName: "HighTemp", AlarmId: "alarm-1"),
new("e-offnormal", Equipment, T0.AddSeconds(1), T0, "off", 500, EventTypeName: "OffNormalAlarm", ConditionName: "Door", AlarmId: "alarm-gone"),
new("e-untyped", Equipment, T0.AddSeconds(2), T0, "x", 300),
new("e-bogus", Equipment, T0.AddSeconds(3), T0, "y", 900, EventTypeName: "Folder", ConditionName: "Odd"),
];
[Fact]
public async Task EventType_ConditionName_and_a_resolvable_SourceNode_are_projected()
{
var (host, server, nm, notifier) = await BootAsync();
var condition = nm.TryGetAlarmCondition("alarm-1")!.NodeId;
var history = Read(server, nm, notifier, Select("EventId", "EventType", "ConditionName", "SourceNode"), where: null);
var rows = history.Events.ToDictionary(e => System.Text.Encoding.UTF8.GetString((byte[])e.EventFields[0].Value));
rows["e-limit"].EventFields[1].Value.ShouldBe(ObjectTypeIds.LimitAlarmType);
rows["e-limit"].EventFields[2].Value.ShouldBe("HighTemp");
rows["e-limit"].EventFields[3].Value.ShouldBe(condition, "the alarm id resolves to the materialised condition — the live events' SourceNode");
rows["e-offnormal"].EventFields[1].Value.ShouldBe(ObjectTypeIds.OffNormalAlarmType);
rows["e-offnormal"].EventFields[3].Value.ShouldBeNull("no condition is materialised for that alarm id");
// No stored type: still an alarm condition (every historical row is alarm history); an unrecognised or non-event
// type name is not passed through as a non-event type.
rows["e-untyped"].EventFields[1].Value.ShouldBe(ObjectTypeIds.AlarmConditionType);
rows["e-untyped"].EventFields[2].Value.ShouldBeNull();
rows["e-bogus"].EventFields[1].Value.ShouldBe(ObjectTypeIds.AlarmConditionType);
await host.DisposeAsync();
}
[Fact]
public async Task An_OfType_where_clause_keeps_only_events_of_that_type_or_a_subtype()
{
var (host, server, nm, notifier) = await BootAsync();
Ids(Read(server, nm, notifier, Select("EventId"), OfType(ObjectTypeIds.LimitAlarmType))).ShouldBe(["e-limit"]);
Ids(Read(server, nm, notifier, Select("EventId"), OfType(ObjectTypeIds.OffNormalAlarmType))).ShouldBe(["e-offnormal"]);
// A supertype keeps its subtypes: every row is an AlarmConditionType (positive control — the filter is not
// simply dropping everything typed).
Ids(Read(server, nm, notifier, Select("EventId"), OfType(ObjectTypeIds.AlarmConditionType)))
.ShouldBe(["e-limit", "e-offnormal", "e-untyped", "e-bogus"]);
// An unrelated event type keeps nothing (GoodNoData).
Ids(Read(server, nm, notifier, Select("EventId"), OfType(ObjectTypeIds.AuditEventType))).ShouldBeEmpty();
await host.DisposeAsync();
}
[Fact]
public async Task A_field_where_clause_is_evaluated_against_the_projected_fields()
{
var (host, server, nm, notifier) = await BootAsync();
var where = new ContentFilter();
where.Push(FilterOperator.GreaterThan,
new SimpleAttributeOperand(ObjectTypeIds.BaseEventType, new QualifiedName("Severity")) { AttributeId = Attributes.Value },
new LiteralOperand(new Variant((ushort)600)));
Ids(Read(server, nm, notifier, Select("EventId"), where)).ShouldBe(["e-limit", "e-bogus"]);
await host.DisposeAsync();
}
private static string[] Ids(HistoryEvent h) =>
h.Events.Select(e => System.Text.Encoding.UTF8.GetString((byte[])e.EventFields[0].Value)).ToArray();
private static ContentFilter OfType(NodeId type)
{
var f = new ContentFilter();
f.Push(FilterOperator.OfType, new LiteralOperand(new Variant(type)));
return f;
}
private static SimpleAttributeOperandCollection Select(params string[] leaves)
{
var c = new SimpleAttributeOperandCollection();
foreach (var leaf in leaves)
c.Add(new SimpleAttributeOperand(ObjectTypeIds.BaseEventType, new QualifiedName(leaf)) { AttributeId = Attributes.Value });
return c;
}
private static HistoryEvent Read(OtOpcUaSdkServer server, OtOpcUaNodeManager nm, NodeId notifier,
SimpleAttributeOperandCollection select, ContentFilter? where)
{
var details = new ReadEventDetails
{
StartTime = T0.AddHours(-1),
EndTime = T0.AddHours(1),
NumValuesPerNode = 100,
Filter = new EventFilter { SelectClauses = select, WhereClause = where ?? new ContentFilter() },
};
var context = new OperationContext(new RequestHeader(), secureChannelContext: null, RequestType.HistoryRead, identity: null);
var results = new List<SdkHistoryReadResult> { null! };
var errors = new List<ServiceResult> { null! };
nm.HistoryRead(context, details, TimestampsToReturn.Both, releaseContinuationPoints: false,
new List<HistoryReadValueId> { new() { NodeId = notifier } }, results, errors);
StatusCode.IsGood(errors[0].StatusCode).ShouldBeTrue($"HistoryRead failed: {errors[0].StatusCode}");
return (HistoryEvent)ExtensionObject.ToEncodeable(results[0].HistoryData);
}
private async Task<(OpcUaApplicationHost Host, OtOpcUaSdkServer Server, OtOpcUaNodeManager Nm, NodeId Notifier)> BootAsync()
{
var started = await OpcUaHostStart.StartAsync(
port => new OpcUaApplicationHost(
new OpcUaApplicationHostOptions
{
ApplicationName = "OtOpcUa.HistoryReadEventsProjectionTest",
ApplicationUri = $"urn:OtOpcUa.HistoryReadEventsProjectionTest:{Guid.NewGuid():N}",
OpcUaPort = port,
PublicHostname = "localhost",
PkiStoreRoot = _pkiRoot,
},
NullLogger<OpcUaApplicationHost>.Instance),
() => new OtOpcUaSdkServer(), Ct);
var nm = started.Server.NodeManager!;
nm.HistorianDataSource = new FixedEventsSource(Events);
nm.EnsureFolder(Equipment, parentNodeId: null, displayName: "Equipment", realm: AddressSpaceRealm.Uns);
nm.MaterialiseAlarmCondition("alarm-1", Equipment, "HighTemp", "LimitAlarm", severity: 700, realm: AddressSpaceRealm.Uns);
return (started.Host, started.Server, nm, nm.TryGetFolder(Equipment)!.NodeId);
}
private sealed class FixedEventsSource(IReadOnlyList<HistoricalEvent> events) : IHistorianDataSource
{
public Task<HistorianRead> ReadRawAsync(string fullReference, DateTime startUtc, DateTime endUtc, uint maxValuesPerNode,
CancellationToken cancellationToken) =>
NullHistorianDataSource.Instance.ReadRawAsync(fullReference, startUtc, endUtc, maxValuesPerNode, cancellationToken);
public Task<HistorianRead> ReadProcessedAsync(string fullReference, DateTime startUtc, DateTime endUtc, TimeSpan interval,
HistoryAggregateType aggregate, CancellationToken cancellationToken) =>
NullHistorianDataSource.Instance.ReadProcessedAsync(fullReference, startUtc, endUtc, interval, aggregate, cancellationToken);
public Task<HistorianRead> ReadAtTimeAsync(string fullReference, IReadOnlyList<DateTime> timestampsUtc,
CancellationToken cancellationToken) =>
NullHistorianDataSource.Instance.ReadAtTimeAsync(fullReference, timestampsUtc, cancellationToken);
public Task<HistoricalEventsResult> ReadEventsAsync(string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents,
CancellationToken cancellationToken) =>
Task.FromResult(new HistoricalEventsResult(events.Where(e => e.EventTimeUtc >= startUtc && e.EventTimeUtc < endUtc).ToList(), null));
public HistorianHealthSnapshot GetHealthSnapshot() => NullHistorianDataSource.Instance.GetHealthSnapshot();
public void Dispose() { }
}
public void Dispose()
{
if (Directory.Exists(_pkiRoot))
{
try { Directory.Delete(_pkiRoot, recursive: true); }
catch { /* best-effort cleanup */ }
}
}
}