380ec541c2
v2-ci / unit-tests (push) Has been cancelled
Item 1 of #776 (the B2 over-the-wire ask): Client.CLI had no way to issue a HistoryReadEvents. `historyread --events` now sends ReadEventDetails to an event notifier and prints one line per event in select-clause order. --fields picks the select clauses (default EventId, EventType, Time, SourceName, Severity, Message, ConditionName (on ConditionType), SourceNode); --of-type adds an OfType where-clause. --events is exclusive with --modified / --aggregate, and --fields / --of-type require it. Client.Shared gains IOpcUaClientService/ISessionAdapter.HistoryReadEventsAsync (default-implemented as NotSupported for other implementations) backed by the real session adapter through HistoryReadPaging, so continuation points are followed up to --max and a Bad node status is surfaced, not printed as empty. Tests: CLI tests over the fake service (default filter, --fields/--of-type filter, option conflicts); an over-the-wire test drives the shared client's event read against the in-process server — the default fields come back non-null, OfType filters, an invalid where-clause is BadEventFilterInvalid. The other #776 items are decisions and are left open. Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
177 lines
9.5 KiB
C#
177 lines
9.5 KiB
C#
using Opc.Ua;
|
|
using Opc.Ua.Client;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Client.Shared;
|
|
using ZB.MOM.WW.OtOpcUa.Client.Shared.Models;
|
|
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(); }
|
|
}
|
|
|
|
/// <summary>
|
|
/// #776: the shared client's event-history read — the code behind Client.CLI's <c>historyread --events</c> — over
|
|
/// the wire with the real session adapter: the default CLI select clauses come back non-null, <c>--of-type</c>
|
|
/// filters, and an invalid where-clause is refused as <c>BadEventFilterInvalid</c> (#778), not printed as no events.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task The_shared_client_reads_event_history_with_projection_and_OfType()
|
|
{
|
|
var ct = TestContext.Current.CancellationToken;
|
|
var rig = await StartAsync("EvtHistoryClient", 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 client = new OpcUaClientService();
|
|
await client.ConnectAsync(new ConnectionSettings
|
|
{
|
|
EndpointUrl = rig.EndpointUrl,
|
|
CertificateStorePath = Path.Combine(Path.GetTempPath(), $"otopcua-clientpki-{Guid.NewGuid():N}"),
|
|
}, ct);
|
|
ushort rawNs;
|
|
using (var session = await rig.ConnectAsync(anonymous: true, ct))
|
|
rawNs = (ushort)session.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri);
|
|
var node = new NodeId(RawDeviceFolder, rawNs);
|
|
|
|
// The CLI's default fields: EventId, EventType, Time, SourceName, Severity, Message, ConditionName, SourceNode.
|
|
var all = await client.HistoryReadEventsAsync(node, Epoch.AddDays(-1), Epoch.AddDays(1), Filter(ofType: null), 100, ct);
|
|
all.Count.ShouldBe(2);
|
|
foreach (var e in all)
|
|
e.Fields.ShouldAllBe(f => f.Value != null, "every default field is projected");
|
|
all[0].Fields[1].Value.ShouldBe(ObjectTypeIds.LimitAlarmType);
|
|
all[0].Fields[4].Value.ShouldBe((ushort)700);
|
|
all[0].Fields[6].Value.ShouldBe("temp_hi");
|
|
|
|
var limits = await client.HistoryReadEventsAsync(node, Epoch.AddDays(-1), Epoch.AddDays(1),
|
|
Filter(ObjectTypeIds.LimitAlarmType), 100, ct);
|
|
limits.Select(e => System.Text.Encoding.UTF8.GetString((byte[])e.Fields[0].Value)).ShouldBe(["e-limit"]);
|
|
|
|
var invalid = Filter(ofType: null);
|
|
invalid.WhereClause.Push(FilterOperator.Equals, new LiteralOperand(new Variant(1)));
|
|
var refused = await Should.ThrowAsync<ServiceResultException>(() =>
|
|
client.HistoryReadEventsAsync(node, Epoch.AddDays(-1), Epoch.AddDays(1), invalid, 100, ct));
|
|
refused.StatusCode.ShouldBe(StatusCodes.BadEventFilterInvalid);
|
|
}
|
|
finally { await rig.DisposeAsync(); }
|
|
|
|
static EventFilter Filter(NodeId? ofType)
|
|
{
|
|
var filter = new EventFilter();
|
|
foreach (var leaf in new[] { "EventId", "EventType", "Time", "SourceName", "Severity", "Message", "ConditionName", "SourceNode" })
|
|
{
|
|
var type = leaf == "ConditionName" ? ObjectTypeIds.ConditionType : ObjectTypeIds.BaseEventType;
|
|
filter.SelectClauses.Add(new SimpleAttributeOperand(type, new QualifiedName(leaf)) { AttributeId = Attributes.Value });
|
|
}
|
|
if (ofType is not null) filter.WhereClause.Push(FilterOperator.OfType, new LiteralOperand(new Variant(ofType)));
|
|
return filter;
|
|
}
|
|
}
|
|
|
|
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() { }
|
|
}
|
|
}
|