f966efcfad
C1 (critical): B6 computes the plan off the pinned thread while B1 made writes non-creating, so a value/status/alarm write for a node the in-flight plan adds was skipped, and change-driven drivers never republish it (BadWaitingForInitialData indefinitely; on a full rebuild every re-subscribe value landed on discarded nodes). OpcUaPublishActor now applies writes immediately AND remembers the latest per (realm, id) while a plan is in flight or queued (value+status compose; alarm state/quality coalesce to the latest, which the node's delta gate makes inert on an existing condition), replays it after each apply before answering, and drops it once the pipeline is idle. Bounded (250k keys by default; beyond it new keys are not held, logged once). I2: the data-plane-authority parse runs in its own try inside the plan task; a throw is logged and never degrades the address-space apply. I3: the SDK's DeleteMonitoredItems releases an item's component-cache reference on a Good delete; a tombstoned item's reference was already released, so the delete took one from the re-created node's entry. Released items are remembered and the orphan delete takes one back first. Event items never held a reference (EventManager + SubscribeToEvents), so the three event-item releases are gone. I4: direct tests for the HistoryUpdate override's per-request updater check and the HistoryReadEvents override's source guard (both reached by restoring the bit after a reset - the B7 race window); fixed the false remark. Minors: the MaterialiseAlarmCondition kind-swap tombstones the dropped instance; a bytes-less rebuild arriving mid-plan queues behind it (replies in order); a PlanReady/PlanFailed from a previous actor incarnation is ignored and its requester answered degraded; the tie-cluster end bound of an uncapped read fits against the backend ceiling (defence in depth - guarded by a sweep test); the lock-time benchmark stays in the default run, headroom noted. Refs #732 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
505 lines
24 KiB
C#
505 lines
24 KiB
C#
using Microsoft.Extensions.Logging.Abstractions;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
|
using Opc.Ua;
|
|
using Opc.Ua.Server;
|
|
using Shouldly;
|
|
using Xunit;
|
|
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>
|
|
/// Phase C Task 4 — the node-manager's OPC UA HistoryReadEvents override over equipment-folder
|
|
/// event-notifier nodes. Boots a real <see cref="OtOpcUaSdkServer"/> (the same harness Task 3 uses),
|
|
/// wires a recording fake <see cref="IHistorianDataSource"/> BEFORE materialising an alarm condition
|
|
/// (so the alarm-owning equipment folder is promoted to an event notifier WITH the HistoryRead bit),
|
|
/// then invokes the node manager's PUBLIC <c>HistoryRead(OperationContext, …)</c> with a
|
|
/// <see cref="ReadEventDetails"/>. The base CustomNodeManager2 builds the node handles + dispatches to
|
|
/// the protected <c>HistoryReadEvents</c> override, so this exercises the real dispatch path
|
|
/// in-process — fast + deterministic, no client socket.
|
|
/// </summary>
|
|
public sealed class NodeManagerHistoryReadEventsTests : IDisposable
|
|
{
|
|
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
|
|
|
private readonly string _pkiRoot = Path.Combine(
|
|
Path.GetTempPath(),
|
|
$"otopcua-historyreadevents-{Guid.NewGuid():N}");
|
|
|
|
/// <summary>Happy path: the fake receives (sourceName == the equipment-folder id, StartTime, EndTime,
|
|
/// maxEvents), and each returned event decodes to a HistoryEventFieldList whose EventFields are in
|
|
/// SelectClause ORDER with correctly-typed Variants (EventId ByteString, SourceName string, Time
|
|
/// DateTime, ReceiveTime DateTime, Message LocalizedText, Severity UInt16). StatusCode is Good when
|
|
/// events are present.</summary>
|
|
[Fact]
|
|
public async Task Events_dispatches_to_source_and_projects_fields_in_select_order()
|
|
{
|
|
var (host, server) = await BootAsync();
|
|
var nm = server.NodeManager!;
|
|
var fake = new RecordingHistorianDataSource();
|
|
// Wire the source BEFORE materialising the alarm so the folder gets the HistoryRead bit.
|
|
nm.HistorianDataSource = fake;
|
|
|
|
const string equipmentId = "eq-evt";
|
|
nm.EnsureFolder(equipmentId, parentNodeId: null, displayName: "Equipment", realm: AddressSpaceRealm.Uns);
|
|
nm.MaterialiseAlarmCondition("alarm-1", equipmentId, "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns);
|
|
var notifierNodeId = nm.TryGetFolder(equipmentId)!.NodeId;
|
|
|
|
var evtTime = new DateTime(2026, 6, 14, 10, 0, 0, DateTimeKind.Utc);
|
|
var rcvTime = new DateTime(2026, 6, 14, 10, 0, 1, DateTimeKind.Utc);
|
|
fake.EventsResult = new HistoricalEventsResult(
|
|
new[] { new HistoricalEvent("evt-42", "Pump_001", evtTime, rcvTime, "Pump tripped", 700) }, null);
|
|
|
|
var start = DateTime.UtcNow.AddHours(-1);
|
|
var end = DateTime.UtcNow;
|
|
var details = new ReadEventDetails
|
|
{
|
|
StartTime = start,
|
|
EndTime = end,
|
|
NumValuesPerNode = 50,
|
|
// ReceiveTime is included to verify it projects evt.ReceivedTimeUtc at index 3.
|
|
Filter = SelectFilter("EventId", "SourceName", "Time", "ReceiveTime", "Message", "Severity"),
|
|
};
|
|
|
|
var (results, errors) = InvokeHistoryRead(server, nm, details, notifierNodeId);
|
|
|
|
// The source saw the equipment/folder id as the sourceName + the request window + cap.
|
|
fake.LastSourceName.ShouldBe(equipmentId);
|
|
fake.LastStart.ShouldBe(start);
|
|
fake.LastEnd.ShouldBe(end);
|
|
fake.LastMaxEvents.ShouldBe(50);
|
|
|
|
errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good);
|
|
results[0].StatusCode.Code.ShouldBe(StatusCodes.Good);
|
|
|
|
var history = (HistoryEvent)ExtensionObject.ToEncodeable(results[0].HistoryData);
|
|
history.Events.Count.ShouldBe(1);
|
|
var fields = history.Events[0].EventFields;
|
|
fields.Count.ShouldBe(6);
|
|
|
|
// EventId ⇒ ByteString (UTF-8 of "evt-42").
|
|
fields[0].Value.ShouldBeOfType<byte[]>().ShouldBe(System.Text.Encoding.UTF8.GetBytes("evt-42"));
|
|
// SourceName ⇒ string.
|
|
fields[1].Value.ShouldBe("Pump_001");
|
|
// Time ⇒ DateTime (event occurrence time).
|
|
fields[2].Value.ShouldBe(evtTime);
|
|
// ReceiveTime ⇒ DateTime (server receipt time = ReceivedTimeUtc).
|
|
fields[3].Value.ShouldBe(rcvTime);
|
|
// Message ⇒ LocalizedText.
|
|
fields[4].Value.ShouldBeOfType<LocalizedText>().Text.ShouldBe("Pump tripped");
|
|
// Severity ⇒ UInt16.
|
|
fields[5].Value.ShouldBe((ushort)700);
|
|
|
|
await host.DisposeAsync();
|
|
}
|
|
|
|
/// <summary>OpcUaServer-002: a HistoryReadEvents with <c>NumValuesPerNode == 0</c> means "no limit —
|
|
/// return ALL values" per OPC UA Part 4/11, so the backend must receive an UNBOUNDED cap
|
|
/// (<see cref="int.MaxValue"/>), NOT the <c>maxEvents <= 0</c> "use the default cap" sentinel that
|
|
/// would silently truncate a whole-window read.</summary>
|
|
[Fact]
|
|
public async Task Events_unbounded_request_passes_int_max_to_backend()
|
|
{
|
|
var (host, server) = await BootAsync();
|
|
var nm = server.NodeManager!;
|
|
var fake = new RecordingHistorianDataSource();
|
|
nm.HistorianDataSource = fake;
|
|
|
|
const string equipmentId = "eq-unbounded";
|
|
nm.EnsureFolder(equipmentId, parentNodeId: null, displayName: "Equipment", realm: AddressSpaceRealm.Uns);
|
|
nm.MaterialiseAlarmCondition("alarm-0", equipmentId, "Cond", "OffNormalAlarm", severity: 600, realm: AddressSpaceRealm.Uns);
|
|
var notifierNodeId = nm.TryGetFolder(equipmentId)!.NodeId;
|
|
|
|
fake.EventsResult = new HistoricalEventsResult(
|
|
new[] { new HistoricalEvent("evt-x", "Src", DateTime.UtcNow, DateTime.UtcNow, "msg", 600) }, null);
|
|
|
|
var details = new ReadEventDetails
|
|
{
|
|
StartTime = DateTime.UtcNow.AddHours(-1),
|
|
EndTime = DateTime.UtcNow,
|
|
// 0 ⇒ "no limit" — the override must translate this to int.MaxValue for the backend.
|
|
NumValuesPerNode = 0,
|
|
Filter = SelectFilter("EventId"),
|
|
};
|
|
|
|
var (_, errors) = InvokeHistoryRead(server, nm, details, notifierNodeId);
|
|
|
|
errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good);
|
|
// The backend saw the unbounded cap, NOT the 0/default-cap sentinel.
|
|
fake.LastMaxEvents.ShouldBe(int.MaxValue);
|
|
|
|
await host.DisposeAsync();
|
|
}
|
|
|
|
/// <summary>An unsupported select operand (BrowsePath ["EventType"]) projects to Variant.Null — a field
|
|
/// the server can't supply is null (spec-conformant) — while supported siblings still project.</summary>
|
|
[Fact]
|
|
public async Task Events_unsupported_select_field_projects_null()
|
|
{
|
|
var (host, server) = await BootAsync();
|
|
var nm = server.NodeManager!;
|
|
var fake = new RecordingHistorianDataSource();
|
|
nm.HistorianDataSource = fake;
|
|
|
|
const string equipmentId = "eq-unsupported";
|
|
nm.EnsureFolder(equipmentId, parentNodeId: null, displayName: "Equipment", realm: AddressSpaceRealm.Uns);
|
|
nm.MaterialiseAlarmCondition("alarm-2", equipmentId, "Cond", "OffNormalAlarm", severity: 500, realm: AddressSpaceRealm.Uns);
|
|
var notifierNodeId = nm.TryGetFolder(equipmentId)!.NodeId;
|
|
|
|
fake.EventsResult = new HistoricalEventsResult(
|
|
new[] { new HistoricalEvent("evt-1", "Src", DateTime.UtcNow, DateTime.UtcNow, "msg", 500) }, null);
|
|
|
|
var details = new ReadEventDetails
|
|
{
|
|
StartTime = DateTime.UtcNow.AddHours(-1),
|
|
EndTime = DateTime.UtcNow,
|
|
NumValuesPerNode = 10,
|
|
// EventType is a real BaseEventType field we cannot supply from a HistoricalEvent; EventId is.
|
|
Filter = SelectFilter("EventType", "EventId"),
|
|
};
|
|
|
|
var (results, errors) = InvokeHistoryRead(server, nm, details, notifierNodeId);
|
|
|
|
errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good);
|
|
var history = (HistoryEvent)ExtensionObject.ToEncodeable(results[0].HistoryData);
|
|
var fields = history.Events[0].EventFields;
|
|
fields.Count.ShouldBe(2);
|
|
// EventType ⇒ Variant.Null.
|
|
fields[0].Value.ShouldBeNull();
|
|
// EventId still projects to a ByteString.
|
|
fields[1].Value.ShouldBeOfType<byte[]>().ShouldBe(System.Text.Encoding.UTF8.GetBytes("evt-1"));
|
|
|
|
await host.DisposeAsync();
|
|
}
|
|
|
|
/// <summary>Empty events ⇒ the notifier's StatusCode is GoodNoData (the source is wired, the window
|
|
/// just held no events).</summary>
|
|
[Fact]
|
|
public async Task Events_empty_yields_GoodNoData()
|
|
{
|
|
var (host, server) = await BootAsync();
|
|
var nm = server.NodeManager!;
|
|
var fake = new RecordingHistorianDataSource
|
|
{
|
|
EventsResult = new HistoricalEventsResult(Array.Empty<HistoricalEvent>(), null),
|
|
};
|
|
nm.HistorianDataSource = fake;
|
|
|
|
const string equipmentId = "eq-empty";
|
|
nm.EnsureFolder(equipmentId, parentNodeId: null, displayName: "Equipment", realm: AddressSpaceRealm.Uns);
|
|
nm.MaterialiseAlarmCondition("alarm-3", equipmentId, "Cond", "OffNormalAlarm", severity: 300, realm: AddressSpaceRealm.Uns);
|
|
var notifierNodeId = nm.TryGetFolder(equipmentId)!.NodeId;
|
|
|
|
var details = new ReadEventDetails
|
|
{
|
|
StartTime = DateTime.UtcNow.AddHours(-1),
|
|
EndTime = DateTime.UtcNow,
|
|
NumValuesPerNode = 10,
|
|
Filter = SelectFilter("EventId"),
|
|
};
|
|
|
|
var (results, errors) = InvokeHistoryRead(server, nm, details, notifierNodeId);
|
|
|
|
errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good);
|
|
results[0].StatusCode.Code.ShouldBe(StatusCodes.GoodNoData);
|
|
var history = (HistoryEvent)ExtensionObject.ToEncodeable(results[0].HistoryData);
|
|
history.Events.ShouldBeEmpty();
|
|
|
|
await host.DisposeAsync();
|
|
}
|
|
|
|
/// <summary>A folder promoted to an event notifier while NO historian was wired (Null source at
|
|
/// materialise time) is not an event-history source ⇒ a HistoryReadEvents over it yields
|
|
/// BadHistoryOperationUnsupported. Wiring a real source AFTER promotion re-evaluates the folder
|
|
/// (review 01 U3 / B7 — before B7 it stayed unsupported until a rebuild), so the same read then reaches
|
|
/// the source.</summary>
|
|
[Fact]
|
|
public async Task Events_folder_promoted_without_source_is_unsupported_until_a_source_is_wired()
|
|
{
|
|
var (host, server) = await BootAsync();
|
|
var nm = server.NodeManager!;
|
|
|
|
const string equipmentId = "eq-nosrc";
|
|
nm.EnsureFolder(equipmentId, parentNodeId: null, displayName: "Equipment", realm: AddressSpaceRealm.Uns);
|
|
nm.MaterialiseAlarmCondition("alarm-4", equipmentId, "Cond", "OffNormalAlarm", severity: 200, realm: AddressSpaceRealm.Uns);
|
|
var notifierNodeId = nm.TryGetFolder(equipmentId)!.NodeId;
|
|
|
|
var details = new ReadEventDetails
|
|
{
|
|
StartTime = DateTime.UtcNow.AddHours(-1),
|
|
EndTime = DateTime.UtcNow,
|
|
NumValuesPerNode = 10,
|
|
Filter = SelectFilter("EventId"),
|
|
};
|
|
|
|
var (_, unsupported) = InvokeHistoryRead(server, nm, details, notifierNodeId);
|
|
unsupported[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported);
|
|
|
|
var fake = new RecordingHistorianDataSource();
|
|
nm.HistorianDataSource = fake;
|
|
|
|
var (_, errors) = InvokeHistoryRead(server, nm, details, notifierNodeId);
|
|
|
|
errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good);
|
|
fake.EventsCalled.ShouldBeTrue();
|
|
fake.LastSourceName.ShouldBe(equipmentId);
|
|
|
|
await host.DisposeAsync();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Review fix I4 — the HistoryReadEvents override's OWN source guard. Every other "unsupported" case here is
|
|
/// answered by the SDK base, which admits only notifiers carrying the <c>EventNotifier.HistoryRead</c> bit —
|
|
/// and since B7 the bit and the source registration move together. So the guard is reached only by a request
|
|
/// the base admitted on the bit just before a source reset took the registration back; reconstructed by
|
|
/// restoring the bit after the reset. The override must answer unsupported on both slots and reach no source.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task The_event_override_refuses_a_notifier_with_the_bit_but_no_registered_source()
|
|
{
|
|
var (host, server) = await BootAsync();
|
|
var nm = server.NodeManager!;
|
|
var fake = new RecordingHistorianDataSource();
|
|
nm.HistorianDataSource = fake;
|
|
nm.EnsureFolder("eq-guard", parentNodeId: null, displayName: "Equipment", realm: AddressSpaceRealm.Uns);
|
|
nm.MaterialiseAlarmCondition("alarm-g", "eq-guard", "Cond", "OffNormalAlarm", severity: 200, realm: AddressSpaceRealm.Uns);
|
|
nm.IsEventHistorySource("eq-guard").ShouldBeTrue("non-vacuity: registered while the source was wired");
|
|
|
|
nm.HistorianDataSource = null!;
|
|
var folder = nm.TryGetFolder("eq-guard")!;
|
|
nm.IsEventHistorySource("eq-guard").ShouldBeFalse();
|
|
folder.EventNotifier |= EventNotifiers.HistoryRead;
|
|
|
|
var details = new ReadEventDetails
|
|
{
|
|
StartTime = DateTime.UtcNow.AddHours(-1),
|
|
EndTime = DateTime.UtcNow,
|
|
NumValuesPerNode = 10,
|
|
Filter = SelectFilter("EventId"),
|
|
};
|
|
var (results, errors) = InvokeHistoryRead(server, nm, details, folder.NodeId);
|
|
|
|
errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported);
|
|
results[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported, "set by the override's guard");
|
|
fake.EventsCalled.ShouldBeFalse();
|
|
|
|
await host.DisposeAsync();
|
|
}
|
|
|
|
/// <summary>A variable node targeted by a HistoryReadEvents request ⇒ BadHistoryOperationUnsupported;
|
|
/// the source is never invoked.
|
|
/// <para>
|
|
/// NOTE — what this test actually pins: the SDK base (CustomNodeManager2.HistoryRead) filters
|
|
/// event-history reads by the <c>EventNotifier.HistoryRead</c> bit, NOT by
|
|
/// <c>AccessLevel.HistoryRead</c>. A variable node carries <c>AccessLevel.HistoryRead</c> (for
|
|
/// variable-history reads) but <c>EventNotifier = None</c> (no event-notifier bits at all). The
|
|
/// SDK base therefore rejects it and does NOT pass it to our <c>HistoryReadEvents</c> override;
|
|
/// the Bad result comes from the base's pre-seeding, not from our source-guard. This test pins
|
|
/// that base-level rejection of variable nodes for event reads.
|
|
/// The override's own source-guard (a miss in <c>_eventNotifierSources</c>) is exercised by
|
|
/// <c>The_event_override_refuses_a_notifier_with_the_bit_but_no_registered_source</c>.
|
|
/// </para>
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Events_non_source_node_yields_BadHistoryOperationUnsupported()
|
|
{
|
|
var (host, server) = await BootAsync();
|
|
var nm = server.NodeManager!;
|
|
var fake = new RecordingHistorianDataSource();
|
|
nm.HistorianDataSource = fake;
|
|
|
|
// A historized variable node — has AccessLevel.HistoryRead (variable-history reads) but
|
|
// EventNotifier=None (no event-notifier bit). The SDK base rejects it before our override runs.
|
|
nm.EnsureVariable("eq-1/temp", parentFolderNodeId: null, displayName: "Temp", dataType: "Float",
|
|
writable: false, historianTagname: "WW.Temp", realm: AddressSpaceRealm.Uns);
|
|
var nodeId = nm.TryGetVariable("eq-1/temp")!.NodeId;
|
|
|
|
var details = new ReadEventDetails
|
|
{
|
|
StartTime = DateTime.UtcNow.AddHours(-1),
|
|
EndTime = DateTime.UtcNow,
|
|
NumValuesPerNode = 10,
|
|
Filter = SelectFilter("EventId"),
|
|
};
|
|
|
|
var (_, errors) = InvokeHistoryRead(server, nm, details, nodeId);
|
|
|
|
errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported);
|
|
fake.EventsCalled.ShouldBeFalse();
|
|
|
|
await host.DisposeAsync();
|
|
}
|
|
|
|
/// <summary>A backend that throws ⇒ that node's error is Bad and no exception escapes the
|
|
/// HistoryRead call. The fake source MUST be invoked (proving we reached the bridge) and threw;
|
|
/// the production catch now sets BOTH errors and results explicitly, so both are asserted here
|
|
/// rather than relying on the SDK base pre-seeding results[i].</summary>
|
|
[Theory]
|
|
[InlineData(false)]
|
|
[InlineData(true)]
|
|
public async Task Events_backend_throw_yields_bad_status_and_does_not_escape(bool communication)
|
|
{
|
|
var (host, server) = await BootAsync();
|
|
var nm = server.NodeManager!;
|
|
var fake = new RecordingHistorianDataSource { ThrowOnRead = !communication, ReadException = communication ? new System.IO.IOException("lost connection") : null };
|
|
nm.HistorianDataSource = fake;
|
|
|
|
const string equipmentId = "eq-boom";
|
|
nm.EnsureFolder(equipmentId, parentNodeId: null, displayName: "Equipment", realm: AddressSpaceRealm.Uns);
|
|
nm.MaterialiseAlarmCondition("alarm-5", equipmentId, "Cond", "OffNormalAlarm", severity: 900, realm: AddressSpaceRealm.Uns);
|
|
var notifierNodeId = nm.TryGetFolder(equipmentId)!.NodeId;
|
|
|
|
var details = new ReadEventDetails
|
|
{
|
|
StartTime = DateTime.UtcNow.AddHours(-1),
|
|
EndTime = DateTime.UtcNow,
|
|
NumValuesPerNode = 10,
|
|
Filter = SelectFilter("EventId"),
|
|
};
|
|
|
|
// The call must not throw even though the backend does.
|
|
var (results, errors) = InvokeHistoryRead(server, nm, details, notifierNodeId);
|
|
|
|
// Authoritative per-node signal: errors[0] must be Bad.
|
|
ServiceResult.IsBad(errors[0]).ShouldBeTrue();
|
|
errors[0].StatusCode.Code.ShouldBe(communication ? StatusCodes.BadNoCommunication : StatusCodes.BadHistoryOperationUnsupported);
|
|
// The production catch now sets results[0] explicitly — assert it directly (not relying on base seeding).
|
|
results[0].ShouldNotBeNull();
|
|
results[0].StatusCode.Code.ShouldBe(communication ? StatusCodes.BadNoCommunication : StatusCodes.BadHistoryOperationUnsupported);
|
|
// The source WAS entered (proving the override reached the bridge and the throw was swallowed).
|
|
fake.EventsEntered.ShouldBeTrue();
|
|
// ThrowOnRead fires before EventsCalled/LastSourceName are set — that's the throw path.
|
|
fake.EventsCalled.ShouldBeFalse();
|
|
|
|
await host.DisposeAsync();
|
|
}
|
|
|
|
/// <summary>Build a HistoryReadEvents-style event filter whose select clauses are single-element
|
|
/// BrowsePaths over the supplied BaseEventType leaf field names, in order.</summary>
|
|
private static EventFilter SelectFilter(params string[] leafFieldNames)
|
|
{
|
|
var filter = new EventFilter();
|
|
foreach (var name in leafFieldNames)
|
|
{
|
|
filter.SelectClauses.Add(
|
|
new SimpleAttributeOperand(ObjectTypeIds.BaseEventType, new QualifiedName(name))
|
|
{
|
|
AttributeId = Attributes.Value,
|
|
});
|
|
}
|
|
|
|
return filter;
|
|
}
|
|
|
|
/// <summary>Invoke the node manager's public HistoryRead with a single node, returning the filled
|
|
/// results + errors — same session-less <see cref="OperationContext"/> pattern as Task 3's tests.</summary>
|
|
private static (IList<SdkHistoryReadResult> Results, IList<ServiceResult> Errors) InvokeHistoryRead(
|
|
OtOpcUaSdkServer server, OtOpcUaNodeManager nm, HistoryReadDetails details, NodeId nodeId)
|
|
{
|
|
var context = new OperationContext(
|
|
new RequestHeader(), secureChannelContext: null, RequestType.HistoryRead, identity: null);
|
|
|
|
var nodesToRead = new List<HistoryReadValueId> { new() { NodeId = nodeId } };
|
|
var results = new List<SdkHistoryReadResult> { null! };
|
|
var errors = new List<ServiceResult> { null! };
|
|
|
|
nm.HistoryRead(
|
|
context,
|
|
details,
|
|
TimestampsToReturn.Both,
|
|
releaseContinuationPoints: false,
|
|
nodesToRead,
|
|
results,
|
|
errors);
|
|
|
|
return (results, errors);
|
|
}
|
|
|
|
/// <summary>A recording fake historian source — captures the last ReadEventsAsync call's arguments and
|
|
/// returns a configured result (or throws when <see cref="ThrowOnRead"/> is set). The Raw/Processed/
|
|
/// AtTime reads delegate to the Null source (unused by these tests).</summary>
|
|
private sealed class RecordingHistorianDataSource : IHistorianDataSource
|
|
{
|
|
public bool ThrowOnRead { get; init; }
|
|
public Exception? ReadException { get; init; }
|
|
public HistoricalEventsResult EventsResult { get; set; } =
|
|
new(Array.Empty<HistoricalEvent>(), null);
|
|
|
|
/// <summary>Set on every ReadEventsAsync entry, even when ThrowOnRead causes it to throw
|
|
/// before EventsCalled is set — proves the override reached the bridge.</summary>
|
|
public bool EventsEntered { get; private set; }
|
|
public bool EventsCalled { get; private set; }
|
|
public string? LastSourceName { get; private set; }
|
|
public DateTime LastStart { get; private set; }
|
|
public DateTime LastEnd { get; private set; }
|
|
public int LastMaxEvents { get; private set; }
|
|
|
|
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)
|
|
{
|
|
EventsEntered = true;
|
|
if (ReadException is not null) throw ReadException;
|
|
if (ThrowOnRead) throw new InvalidOperationException("backend boom for events");
|
|
EventsCalled = true;
|
|
LastSourceName = sourceName;
|
|
LastStart = startUtc;
|
|
LastEnd = endUtc;
|
|
LastMaxEvents = maxEvents;
|
|
return Task.FromResult(EventsResult);
|
|
}
|
|
|
|
public HistorianHealthSnapshot GetHealthSnapshot() => NullHistorianDataSource.Instance.GetHealthSnapshot();
|
|
|
|
public void Dispose()
|
|
{
|
|
}
|
|
}
|
|
|
|
private async Task<(OpcUaApplicationHost Host, OtOpcUaSdkServer Server)> BootAsync()
|
|
{
|
|
var applicationUri = $"urn:OtOpcUa.HistoryReadEventsTest:{Guid.NewGuid():N}";
|
|
var started = await OpcUaHostStart.StartAsync(
|
|
port => new OpcUaApplicationHost(
|
|
new OpcUaApplicationHostOptions
|
|
{
|
|
ApplicationName = "OtOpcUa.HistoryReadEventsTest",
|
|
ApplicationUri = applicationUri,
|
|
OpcUaPort = port,
|
|
PublicHostname = "localhost",
|
|
PkiStoreRoot = _pkiRoot,
|
|
},
|
|
NullLogger<OpcUaApplicationHost>.Instance),
|
|
() => new OtOpcUaSdkServer(), Ct);
|
|
var host = started.Host;
|
|
var server = started.Server;
|
|
return (host, server);
|
|
}
|
|
|
|
/// <summary>Cleans up the PKI root directory.</summary>
|
|
public void Dispose()
|
|
{
|
|
if (Directory.Exists(_pkiRoot))
|
|
{
|
|
try { Directory.Delete(_pkiRoot, recursive: true); }
|
|
catch { /* best-effort cleanup */ }
|
|
}
|
|
}
|
|
}
|