Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/EventHistoryProjectionOverTheWireTests.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

108 lines
5.7 KiB
C#

using Opc.Ua;
using Opc.Ua.Client;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using CoreHistoryReadResult = ZB.MOM.WW.OtOpcUa.Core.Abstractions.HistoryReadResult;
using static ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests.ServerEventRig;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
/// <summary>
/// Over a real SDK client session: a HistoryReadEvents whose select clause includes <c>EventType</c>,
/// <c>ConditionName</c> and <c>SourceNode</c> — what a generic alarm viewer asks for — gets non-null values (they all
/// used to be null), and an <c>OfType</c> where-clause is honoured on the wire.
/// </summary>
public sealed class EventHistoryProjectionOverTheWireTests
{
private static readonly DateTime Epoch = new(2026, 10, 22, 0, 0, 0, DateTimeKind.Utc);
[Fact]
public async Task EventType_ConditionName_and_SourceNode_come_back_non_null_and_OfType_filters()
{
var ct = TestContext.Current.CancellationToken;
var rig = await StartAsync("EvtProjectionWire", encrypted: false, ct);
try
{
rig.Nm.HistorianDataSource = new FixedSource(
[
new HistoricalEvent("e-limit", RawDeviceFolder, Epoch, Epoch, "high", 700,
EventTypeName: "LimitAlarm", ConditionName: "temp_hi", AlarmId: RawAlarmPath),
new HistoricalEvent("e-off", RawDeviceFolder, Epoch.AddSeconds(1), Epoch, "door", 500,
EventTypeName: "OffNormalAlarm", ConditionName: "door_open", AlarmId: RawAlarmPath),
]);
rig.MaterialiseNative();
rig.Enforce(false, [Key()]);
using var session = await rig.ConnectAsync(anonymous: true, ct);
var node = new NodeId(RawDeviceFolder, (ushort)session.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri));
var all = await ReadAsync(session, node, where: null, ct);
all.Count.ShouldBe(2);
foreach (var fields in all)
{
fields[1].Value.ShouldNotBeNull("EventType");
fields[2].Value.ShouldNotBeNull("ConditionName");
fields[3].Value.ShouldNotBeNull("SourceNode");
}
all[0][1].Value.ShouldBe(ObjectTypeIds.LimitAlarmType);
all[0][2].Value.ShouldBe("temp_hi");
all[0][3].Value.ShouldBe(ConditionNodeId(session, native: true), "the native condition the alarm id names");
var where = new ContentFilter();
where.Push(FilterOperator.OfType, new LiteralOperand(new Variant(ObjectTypeIds.LimitAlarmType)));
var limits = await ReadAsync(session, node, where, ct);
limits.Select(f => System.Text.Encoding.UTF8.GetString((byte[])f[0].Value)).ShouldBe(["e-limit"]);
}
finally { await rig.DisposeAsync(); }
}
private static async Task<List<VariantCollection>> ReadAsync(ISession session, NodeId node, ContentFilter? where, CancellationToken ct)
{
var filter = new EventFilter { WhereClause = where ?? new ContentFilter() };
foreach (var leaf in new[] { "EventId", "EventType", "ConditionName", "SourceNode" })
{
var type = leaf == "ConditionName" ? ObjectTypeIds.ConditionType : ObjectTypeIds.BaseEventType;
filter.SelectClauses.Add(new SimpleAttributeOperand(type, new QualifiedName(leaf)) { AttributeId = Attributes.Value });
}
var details = new ReadEventDetails
{
StartTime = Epoch.AddDays(-1),
EndTime = Epoch.AddDays(1),
NumValuesPerNode = 100,
Filter = filter,
};
var response = await session.HistoryReadAsync(
null, new ExtensionObject(details), TimestampsToReturn.Both, false,
new HistoryReadValueIdCollection { new HistoryReadValueId { NodeId = node } }, ct);
var result = response.Results.ShouldHaveSingleItem();
StatusCode.IsBad(result.StatusCode).ShouldBeFalse($"HistoryRead failed: {result.StatusCode}");
var he = (HistoryEvent)ExtensionObject.ToEncodeable(result.HistoryData);
return he.Events.Select(e => e.EventFields).ToList();
}
private sealed class FixedSource(IReadOnlyList<HistoricalEvent> events) : IHistorianDataSource
{
public Task<HistoricalEventsResult> ReadEventsAsync(string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents,
CancellationToken ct) =>
Task.FromResult(new HistoricalEventsResult(events.Where(e => e.EventTimeUtc >= startUtc && e.EventTimeUtc < endUtc).ToList(), null));
public Task<CoreHistoryReadResult> ReadRawAsync(string fullReference, DateTime startUtc, DateTime endUtc, uint maxValuesPerNode,
CancellationToken cancellationToken) =>
NullHistorianDataSource.Instance.ReadRawAsync(fullReference, startUtc, endUtc, maxValuesPerNode, cancellationToken);
public Task<CoreHistoryReadResult> ReadProcessedAsync(string fullReference, DateTime startUtc, DateTime endUtc, TimeSpan interval,
HistoryAggregateType aggregate, CancellationToken cancellationToken) =>
NullHistorianDataSource.Instance.ReadProcessedAsync(fullReference, startUtc, endUtc, interval, aggregate, cancellationToken);
public Task<CoreHistoryReadResult> ReadAtTimeAsync(string fullReference, IReadOnlyList<DateTime> timestampsUtc,
CancellationToken cancellationToken) =>
NullHistorianDataSource.Instance.ReadAtTimeAsync(fullReference, timestampsUtc, cancellationToken);
public HistorianHealthSnapshot GetHealthSnapshot() => NullHistorianDataSource.Instance.GetHealthSnapshot();
public void Dispose() { }
}
}