Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadTests.cs
T
Joseph Doherty 8dfa165110 fix(historian): #692 every HistoryRead-Raw page is full — no empty page between values
A resumed raw page asked the backend for only N rows from the boundary timestamp
(inclusive), then trimmed the boundary ties already emitted: it netted N - skip values,
and at N = 1 nothing — the empty "full" page was taken for a stalled tie cluster and a
MaxTieClusterOverfetch + 1 (65,537-row) over-fetch stepped one tick, returning an EMPTY
page with a continuation point between every value (8 values = 17 pages / 25 reads),
forward and reverse, with or without ReturnBounds, on every backend.

Each page's read now asks for N + boundarySkip + 1 rows: the ties it trims, the page and
one lookahead row. The lookahead row is the 'more data' signal, so an exact multiple ends
on its last full page (no trailing empty page). The over-fetch fires only when every row
past the trimmed ties is still on the boundary (a genuine cluster larger than the page);
a cursor left inside a cluster is flagged WithinTieCluster and over-fetches directly.
A page that never leaves the boundary adds the prior skip to its cursor.

Low items: BoundedReadHasAnythingAsync carries its walk hint and, when there is no data,
the end bound it found (HistoryEndBound.Resolved + DataExhausted), so the next page reads
nothing; HistoryBounds.IsPlaceholder is an explicit marker (ConditionalWeakTable over the
NotFound instances) instead of status+null shape, so a stored Bad_BoundNotFound row is data.

Tests: NodeManagerHistoryReadPageFillTests (N=1 every shape/direction/bounds, tie clusters
straddling boundaries at N=1/2/3, exact-multiple, carried end bound + walk hint, stored
placeholder-shaped row). Existing paging tests updated where they encoded the short pages.
2026-10-13 00:42:13 -04:00

852 lines
40 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 3 — the node-manager's OPC UA HistoryRead override (Raw / Processed / AtTime) over
/// historized variable nodes. Boots a real <see cref="OtOpcUaSdkServer"/> (the same harness
/// <see cref="NodeManagerHistorizeTests"/> uses), materialises a historized variable via
/// <see cref="OtOpcUaNodeManager.EnsureVariable"/>, wires a recording fake
/// <see cref="IHistorianDataSource"/>, then invokes the node manager's PUBLIC
/// <c>HistoryRead(OperationContext, …)</c> directly. The base CustomNodeManager2 builds the node
/// handles + dispatches to the protected per-details overrides, so this exercises the real dispatch
/// path in-process — fast + deterministic, no client socket.
/// </summary>
public sealed class NodeManagerHistoryReadTests : IDisposable
{
private static CancellationToken Ct => TestContext.Current.CancellationToken;
private readonly string _pkiRoot = Path.Combine(
Path.GetTempPath(),
$"otopcua-historyread-{Guid.NewGuid():N}");
/// <summary>Raw read: the fake receives the resolved tagname + StartTime/EndTime/NumValuesPerNode,
/// and the returned samples decode to a HistoryData whose DataValues mirror value/status/source+server
/// timestamps. StatusCode is Good when samples are present.</summary>
[Fact]
public async Task Raw_dispatches_to_source_and_maps_samples()
{
var (host, server) = await BootAsync();
var nm = server.NodeManager!;
var fake = new RecordingHistorianDataSource();
nm.HistorianDataSource = fake;
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 src = DateTime.UtcNow.AddSeconds(-5);
var srv = DateTime.UtcNow;
fake.RawResult = new HistorianRead(
new[] { new DataValueSnapshot(42.5f, StatusCodes.Good, src, srv) }, null);
var start = DateTime.UtcNow.AddHours(-1);
var end = DateTime.UtcNow;
var details = new ReadRawModifiedDetails
{
ReturnBounds = false,
StartTime = start,
EndTime = end,
NumValuesPerNode = 100,
IsReadModified = false,
};
var (results, errors) = InvokeHistoryRead(server, nm, details, nodeId);
// The source saw the resolved tagname + the request window + cap.
fake.LastCall.ShouldBe("Raw");
fake.LastTagname.ShouldBe("WW.Temp");
fake.LastStart.ShouldBe(start);
fake.LastEnd.ShouldBe(end);
fake.LastMaxValues.ShouldBe(101u, "the page plus one lookahead row (#692) — it tells the server whether more follows");
errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good);
results[0].StatusCode.Code.ShouldBe(StatusCodes.Good);
var data = (HistoryData)ExtensionObject.ToEncodeable(results[0].HistoryData);
data.DataValues.Count.ShouldBe(1);
var dv = data.DataValues[0];
dv.Value.ShouldBe(42.5f);
dv.StatusCode.Code.ShouldBe(StatusCodes.Good);
dv.SourceTimestamp.ShouldBe(src);
dv.ServerTimestamp.ShouldBe(srv);
await host.DisposeAsync();
}
/// <summary>The resolved tagname that reaches the source is the OVERRIDE tagname (distinct from the
/// NodeId / FullName) — the override resolves the NodeId→tagname map, not the bare NodeId.</summary>
[Fact]
public async Task Raw_resolves_override_tagname_not_node_id()
{
var (host, server) = await BootAsync();
var nm = server.NodeManager!;
var fake = new RecordingHistorianDataSource { RawResult = Empty() };
nm.HistorianDataSource = fake;
// Node id "eq-9/flow" but a DISTINCT historian tagname "Plant.Flow.PV".
nm.EnsureVariable("eq-9/flow", parentFolderNodeId: null, displayName: "Flow", dataType: "Double",
writable: false, historianTagname: "Plant.Flow.PV", realm: AddressSpaceRealm.Uns);
var nodeId = nm.TryGetVariable("eq-9/flow")!.NodeId;
var details = new ReadRawModifiedDetails
{
ReturnBounds = false,
StartTime = DateTime.UtcNow.AddHours(-1),
EndTime = DateTime.UtcNow,
NumValuesPerNode = 10,
// ReadRawModifiedDetails.Initialize() sets m_isReadModified=true (SDK-generated ctor body),
// so the default from new ReadRawModifiedDetails() is TRUE. A plain raw read must explicitly
// clear it to false to avoid hitting the IsReadModified=true early-return in the override.
IsReadModified = false,
};
InvokeHistoryRead(server, nm, details, nodeId);
fake.LastTagname.ShouldBe("Plant.Flow.PV");
await host.DisposeAsync();
}
/// <summary>Empty samples ⇒ the node's StatusCode is GoodNoData (the node is historized, the window
/// just held no data).</summary>
[Fact]
public async Task Raw_empty_samples_yields_GoodNoData()
{
var (host, server) = await BootAsync();
var nm = server.NodeManager!;
var fake = new RecordingHistorianDataSource { RawResult = Empty() };
nm.HistorianDataSource = fake;
nm.EnsureVariable("eq-1/empty", parentFolderNodeId: null, displayName: "Empty", dataType: "Float",
writable: false, historianTagname: "WW.Empty", realm: AddressSpaceRealm.Uns);
var nodeId = nm.TryGetVariable("eq-1/empty")!.NodeId;
var details = new ReadRawModifiedDetails
{
ReturnBounds = false,
StartTime = DateTime.UtcNow.AddHours(-1),
EndTime = DateTime.UtcNow,
NumValuesPerNode = 10,
IsReadModified = false,
};
var (results, errors) = InvokeHistoryRead(server, nm, details, nodeId);
errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good);
results[0].StatusCode.Code.ShouldBe(StatusCodes.GoodNoData);
var data = (HistoryData)ExtensionObject.ToEncodeable(results[0].HistoryData);
data.DataValues.ShouldBeEmpty();
await host.DisposeAsync();
}
/// <summary>A non-historized node (plain variable, no HistoryRead bit) reaching HistoryRead yields
/// BadHistoryOperationUnsupported — the source is never invoked.</summary>
[Fact]
public async Task Non_historized_node_yields_BadHistoryOperationUnsupported()
{
var (host, server) = await BootAsync();
var nm = server.NodeManager!;
var fake = new RecordingHistorianDataSource { RawResult = Empty() };
nm.HistorianDataSource = fake;
// Plain (non-historized) variable — no HistoryRead access bit.
nm.EnsureVariable("eq-1/plain", parentFolderNodeId: null, displayName: "Plain", dataType: "Int32",
writable: false, historianTagname: null, realm: AddressSpaceRealm.Uns);
var nodeId = nm.TryGetVariable("eq-1/plain")!.NodeId;
var details = new ReadRawModifiedDetails
{
ReturnBounds = false,
StartTime = DateTime.UtcNow.AddHours(-1),
EndTime = DateTime.UtcNow,
NumValuesPerNode = 10,
IsReadModified = false, // SDK default is true; this test is about the raw path
};
var (_, errors) = InvokeHistoryRead(server, nm, details, nodeId);
errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported);
fake.LastCall.ShouldBeNull(); // source never reached
await host.DisposeAsync();
}
/// <summary>
/// #668: Raw with IsReadModified=true on a backend that keeps no modification trail (the recording fake has no
/// ReadModifiedAsync override, like Gateway) ⇒ BadHistoryOperationUnsupported, and the RAW read is never
/// invoked in its place.
/// </summary>
[Fact]
public async Task Raw_read_modified_on_a_backend_without_a_trail_yields_BadHistoryOperationUnsupported()
{
var (host, server) = await BootAsync();
var nm = server.NodeManager!;
var fake = new RecordingHistorianDataSource { RawResult = Empty() };
nm.HistorianDataSource = fake;
nm.EnsureVariable("eq-1/mod", parentFolderNodeId: null, displayName: "Mod", dataType: "Float",
writable: false, historianTagname: "WW.Mod", realm: AddressSpaceRealm.Uns);
var nodeId = nm.TryGetVariable("eq-1/mod")!.NodeId;
var details = new ReadRawModifiedDetails
{
ReturnBounds = false,
StartTime = DateTime.UtcNow.AddHours(-1),
EndTime = DateTime.UtcNow,
NumValuesPerNode = 10,
IsReadModified = true,
};
var (results, errors) = InvokeHistoryRead(server, nm, details, nodeId);
errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported);
results[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported);
fake.LastCall.ShouldBeNull(); // the raw read is never served in place of a modified one
// The Null default (no historian configured) answers the same.
nm.HistorianDataSource = NullHistorianDataSource.Instance;
InvokeHistoryRead(server, nm, details, nodeId).Item2[0].StatusCode.Code
.ShouldBe(StatusCodes.BadHistoryOperationUnsupported);
await host.DisposeAsync();
}
/// <summary>Processed read with a known aggregate (Average) reaches the source as
/// HistoryAggregateType.Average + the ProcessingInterval as a TimeSpan.</summary>
[Fact]
public async Task Processed_known_aggregate_dispatches_with_interval()
{
var (host, server) = await BootAsync();
var nm = server.NodeManager!;
var fake = new RecordingHistorianDataSource { ProcessedResult = Empty() };
nm.HistorianDataSource = fake;
nm.EnsureVariable("eq-1/avg", parentFolderNodeId: null, displayName: "Avg", dataType: "Float",
writable: false, historianTagname: "WW.Avg", realm: AddressSpaceRealm.Uns);
var nodeId = nm.TryGetVariable("eq-1/avg")!.NodeId;
var details = new ReadProcessedDetails
{
StartTime = DateTime.UtcNow.AddHours(-1),
EndTime = DateTime.UtcNow,
ProcessingInterval = 10_000.0, // ms
AggregateType = new NodeIdCollection { ObjectIds.AggregateFunction_Average },
};
InvokeHistoryRead(server, nm, details, nodeId);
fake.LastCall.ShouldBe("Processed");
fake.LastTagname.ShouldBe("WW.Avg");
fake.LastAggregate.ShouldBe(HistoryAggregateType.Average);
fake.LastInterval.ShouldBe(TimeSpan.FromMilliseconds(10_000.0));
await host.DisposeAsync();
}
/// <summary>Processed read with an UNKNOWN aggregate NodeId ⇒ BadAggregateNotSupported; the source
/// is never invoked.</summary>
[Fact]
public async Task Processed_unknown_aggregate_yields_BadAggregateNotSupported()
{
var (host, server) = await BootAsync();
var nm = server.NodeManager!;
var fake = new RecordingHistorianDataSource { ProcessedResult = Empty() };
nm.HistorianDataSource = fake;
nm.EnsureVariable("eq-1/sd", parentFolderNodeId: null, displayName: "Sd", dataType: "Float",
writable: false, historianTagname: "WW.Sd", realm: AddressSpaceRealm.Uns);
var nodeId = nm.TryGetVariable("eq-1/sd")!.NodeId;
var details = new ReadProcessedDetails
{
StartTime = DateTime.UtcNow.AddHours(-1),
EndTime = DateTime.UtcNow,
ProcessingInterval = 10_000.0,
// StandardDeviationSample is a real OPC UA aggregate we do not serve.
AggregateType = new NodeIdCollection { ObjectIds.AggregateFunction_StandardDeviationSample },
};
var (_, errors) = InvokeHistoryRead(server, nm, details, nodeId);
errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadAggregateNotSupported);
fake.LastCall.ShouldBeNull();
await host.DisposeAsync();
}
/// <summary>
/// #682: an aggregate the server maps but the wired backend does not declare in
/// <see cref="IHistorianDataSource.SupportedAggregates"/> is refused <c>BadAggregateNotSupported</c> UP FRONT — the
/// backend is never called (the same set is what the AggregateFunctions folders advertise). The Null default
/// declares none, so a processed read with no historian is refused too.
/// </summary>
[Fact]
public async Task Processed_aggregate_outside_the_backend_set_is_refused_without_a_backend_call()
{
var (host, server) = await BootAsync();
var nm = server.NodeManager!;
var fake = new RecordingHistorianDataSource
{
ProcessedResult = Empty(),
Supported = new HashSet<HistoryAggregateType> { HistoryAggregateType.TimeAverage },
};
nm.HistorianDataSource = fake;
nm.EnsureVariable("eq-1/gw", parentFolderNodeId: null, displayName: "Gw", dataType: "Float",
writable: false, historianTagname: "WW.Gw", realm: AddressSpaceRealm.Uns);
var nodeId = nm.TryGetVariable("eq-1/gw")!.NodeId;
ReadProcessedDetails Details(NodeId aggregate) => new()
{
StartTime = DateTime.UtcNow.AddHours(-1),
EndTime = DateTime.UtcNow,
ProcessingInterval = 10_000.0,
AggregateType = new NodeIdCollection { aggregate },
};
// Average is mapped by the server but not in this backend's set ⇒ refused, no call.
InvokeHistoryRead(server, nm, Details(ObjectIds.AggregateFunction_Average), nodeId).Item2[0].StatusCode.Code
.ShouldBe(StatusCodes.BadAggregateNotSupported);
fake.LastCall.ShouldBeNull();
// Positive control: a member of the set reaches the backend.
InvokeHistoryRead(server, nm, Details(ObjectIds.AggregateFunction_TimeAverage), nodeId);
fake.LastCall.ShouldBe("Processed");
fake.LastAggregate.ShouldBe(HistoryAggregateType.TimeAverage);
// No historian: the Null default serves no aggregate.
nm.HistorianDataSource = NullHistorianDataSource.Instance;
InvokeHistoryRead(server, nm, Details(ObjectIds.AggregateFunction_TimeAverage), nodeId).Item2[0].StatusCode.Code
.ShouldBe(StatusCodes.BadAggregateNotSupported);
await host.DisposeAsync();
}
/// <summary>
/// #682 review: an out-of-tree source returning a NULL <see cref="IHistorianDataSource.SupportedAggregates"/> (despite
/// the non-nullable contract) is treated as the interface default — ALL — by BOTH consumers: the processed read
/// dispatches (no NullReferenceException failing the whole request) and HistoryServerCapabilities/AggregateFunctions
/// advertises every mapped aggregate.
/// </summary>
[Fact]
public async Task A_null_supported_set_means_all_for_both_the_read_and_the_advertisement()
{
var (host, server) = await BootAsync();
var nm = server.NodeManager!;
var fake = new RecordingHistorianDataSource { ProcessedResult = Empty(), Supported = null };
nm.HistorianDataSource = fake;
nm.EnsureVariable("eq-1/nul", parentFolderNodeId: null, displayName: "Nul", dataType: "Float",
writable: false, historianTagname: "WW.Nul", realm: AddressSpaceRealm.Uns);
var nodeId = nm.TryGetVariable("eq-1/nul")!.NodeId;
var (_, errors) = InvokeHistoryRead(server, nm, new ReadProcessedDetails
{
StartTime = DateTime.UtcNow.AddHours(-1),
EndTime = DateTime.UtcNow,
ProcessingInterval = 10_000.0,
AggregateType = new NodeIdCollection { ObjectIds.AggregateFunction_StandardDeviationPopulation },
}, nodeId);
StatusCode.IsBad(errors[0].StatusCode).ShouldBeFalse($"got {errors[0].StatusCode}");
fake.LastCall.ShouldBe("Processed");
fake.LastAggregate.ShouldBe(HistoryAggregateType.StandardDeviationPopulation);
var diagnostics = (CustomNodeManager2)nm.Server.DiagnosticsNodeManager;
var folder = diagnostics.FindPredefinedNode(ObjectIds.HistoryServerCapabilities_AggregateFunctions, null)!;
AggregateFunctionMap.ByNodeId.Keys.Count(id => folder.ReferenceExists(ReferenceTypeIds.Organizes, false, id))
.ShouldBe(AggregateFunctionMap.ByNodeId.Count);
// Positive control on the same folder: a real (non-null) set trims it.
nm.HistorianDataSource = NullHistorianDataSource.Instance;
AggregateFunctionMap.ByNodeId.Keys.Count(id => folder.ReferenceExists(ReferenceTypeIds.Organizes, false, id))
.ShouldBe(0);
await host.DisposeAsync();
}
/// <summary>
/// #677: a client <c>AggregateConfiguration</c> that differs from the server defaults is refused per node with
/// <c>BadAggregateConfigurationRejected</c> (it used to be silently ignored); the source is never invoked.
/// </summary>
[Theory]
[InlineData(true, (byte)100, (byte)100, false)] // TreatUncertainAsBad
[InlineData(false, (byte)50, (byte)100, false)] // PercentDataBad
[InlineData(false, (byte)100, (byte)80, false)] // PercentDataGood
[InlineData(false, (byte)100, (byte)100, true)] // UseSlopedExtrapolation
[InlineData(false, (byte)0, (byte)0, false)] // an all-zero structure without UseServerCapabilitiesDefaults
public async Task Processed_with_a_non_default_aggregate_configuration_is_rejected(
bool treatUncertainAsBad, byte percentBad, byte percentGood, bool sloped)
{
var (host, server) = await BootAsync();
var nm = server.NodeManager!;
var fake = new RecordingHistorianDataSource { ProcessedResult = Empty() };
nm.HistorianDataSource = fake;
nm.EnsureVariable("eq-1/cfg", parentFolderNodeId: null, displayName: "Cfg", dataType: "Float",
writable: false, historianTagname: "WW.Cfg", realm: AddressSpaceRealm.Uns);
var nodeId = nm.TryGetVariable("eq-1/cfg")!.NodeId;
var (_, errors) = InvokeHistoryRead(server, nm, new ReadProcessedDetails
{
StartTime = DateTime.UtcNow.AddHours(-1), EndTime = DateTime.UtcNow, ProcessingInterval = 10_000.0,
AggregateType = new NodeIdCollection { ObjectIds.AggregateFunction_TimeAverage },
AggregateConfiguration = new AggregateConfiguration
{
UseServerCapabilitiesDefaults = false, TreatUncertainAsBad = treatUncertainAsBad,
PercentDataBad = percentBad, PercentDataGood = percentGood, UseSlopedExtrapolation = sloped,
},
}, nodeId);
errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadAggregateConfigurationRejected);
fake.LastCall.ShouldBeNull();
await host.DisposeAsync();
}
/// <summary>
/// #677: <c>UseServerCapabilitiesDefaults</c> (whatever the other fields say), or a configuration spelling out exactly
/// the server defaults, is served.
/// </summary>
[Theory]
[InlineData(true, true, (byte)0, (byte)0, true)]
[InlineData(false, false, (byte)100, (byte)100, false)]
public async Task Processed_with_the_server_default_aggregate_configuration_is_served(
bool useServerDefaults, bool treatUncertainAsBad, byte percentBad, byte percentGood, bool sloped)
{
var (host, server) = await BootAsync();
var nm = server.NodeManager!;
var fake = new RecordingHistorianDataSource { ProcessedResult = Empty() };
nm.HistorianDataSource = fake;
nm.EnsureVariable("eq-1/cfg-ok", parentFolderNodeId: null, displayName: "CfgOk", dataType: "Float",
writable: false, historianTagname: "WW.CfgOk", realm: AddressSpaceRealm.Uns);
var nodeId = nm.TryGetVariable("eq-1/cfg-ok")!.NodeId;
var (_, errors) = InvokeHistoryRead(server, nm, new ReadProcessedDetails
{
StartTime = DateTime.UtcNow.AddHours(-1), EndTime = DateTime.UtcNow, ProcessingInterval = 10_000.0,
AggregateType = new NodeIdCollection { ObjectIds.AggregateFunction_TimeAverage },
AggregateConfiguration = new AggregateConfiguration
{
UseServerCapabilitiesDefaults = useServerDefaults, TreatUncertainAsBad = treatUncertainAsBad,
PercentDataBad = percentBad, PercentDataGood = percentGood, UseSlopedExtrapolation = sloped,
},
}, nodeId);
StatusCode.IsGood(errors[0].StatusCode).ShouldBeTrue();
fake.LastCall.ShouldBe("Processed");
fake.LastAggregate.ShouldBe(HistoryAggregateType.TimeAverage);
await host.DisposeAsync();
}
/// <summary>#677: the server's declared aggregate defaults are the SDK's own server defaults.</summary>
[Fact]
public void The_server_aggregate_defaults_agree_with_the_SDK_AggregateManager()
{
var sdk = new Opc.Ua.Server.AggregateManager(null!).GetDefaultConfiguration(null);
var ours = ServerAggregateConfiguration.Defaults;
ours.TreatUncertainAsBad.ShouldBe(sdk.TreatUncertainAsBad);
ours.PercentDataBad.ShouldBe(sdk.PercentDataBad);
ours.PercentDataGood.ShouldBe(sdk.PercentDataGood);
ours.UseSlopedExtrapolation.ShouldBe(sdk.UseSlopedExtrapolation);
ServerAggregateConfiguration.IsServable(sdk).ShouldBeTrue();
ServerAggregateConfiguration.IsServable(null).ShouldBeTrue();
}
/// <summary>#285: a Part 13 aggregate beyond the original five (Interpolative) is forwarded to the
/// backend as its own <see cref="HistoryAggregateType"/> member.</summary>
[Fact]
public async Task Processed_extended_aggregate_dispatches_to_the_source()
{
var (host, server) = await BootAsync();
var nm = server.NodeManager!;
var fake = new RecordingHistorianDataSource { ProcessedResult = Empty() };
nm.HistorianDataSource = fake;
nm.EnsureVariable("eq-1/interp", parentFolderNodeId: null, displayName: "I", dataType: "Float",
writable: false, historianTagname: "WW.Interp", realm: AddressSpaceRealm.Uns);
var nodeId = nm.TryGetVariable("eq-1/interp")!.NodeId;
InvokeHistoryRead(server, nm, new ReadProcessedDetails
{
StartTime = DateTime.UtcNow.AddHours(-1), EndTime = DateTime.UtcNow, ProcessingInterval = 10_000.0,
AggregateType = new NodeIdCollection { ObjectIds.AggregateFunction_Interpolative },
}, nodeId);
fake.LastCall.ShouldBe("Processed");
fake.LastAggregate.ShouldBe(HistoryAggregateType.Interpolative);
await host.DisposeAsync();
}
/// <summary>#671: the Part 13 TimeAverage2 / Total2 aggregate NodeIds map to their own members.</summary>
[Theory]
[InlineData("TimeAverage2")]
[InlineData("Total2")]
public async Task Processed_TimeAverage2_and_Total2_dispatch_to_the_source(string name)
{
var (host, server) = await BootAsync();
var nm = server.NodeManager!;
var fake = new RecordingHistorianDataSource { ProcessedResult = Empty() };
nm.HistorianDataSource = fake;
nm.EnsureVariable("eq-1/tw", parentFolderNodeId: null, displayName: "T", dataType: "Float",
writable: false, historianTagname: "WW.Tw", realm: AddressSpaceRealm.Uns);
var nodeId = nm.TryGetVariable("eq-1/tw")!.NodeId;
var (aggregateId, expected) = name == "Total2"
? (ObjectIds.AggregateFunction_Total2, HistoryAggregateType.Total2)
: (ObjectIds.AggregateFunction_TimeAverage2, HistoryAggregateType.TimeAverage2);
InvokeHistoryRead(server, nm, new ReadProcessedDetails
{
StartTime = DateTime.UtcNow.AddHours(-1), EndTime = DateTime.UtcNow, ProcessingInterval = 10_000.0,
AggregateType = new NodeIdCollection { aggregateId },
}, nodeId);
fake.LastCall.ShouldBe("Processed");
fake.LastAggregate.ShouldBe(expected);
await host.DisposeAsync();
}
/// <summary>#285: a backend that cannot serve a mapped aggregate answers BadAggregateNotSupported
/// (not the generic BadHistoryOperationUnsupported a plain throw produces).</summary>
[Fact]
public async Task Processed_backend_unsupported_aggregate_yields_BadAggregateNotSupported()
{
var (host, server) = await BootAsync();
var nm = server.NodeManager!;
nm.HistorianDataSource = new RecordingHistorianDataSource { AggregateNotSupported = true };
nm.EnsureVariable("eq-1/ns", parentFolderNodeId: null, displayName: "N", dataType: "Float",
writable: false, historianTagname: "WW.Ns", realm: AddressSpaceRealm.Uns);
var nodeId = nm.TryGetVariable("eq-1/ns")!.NodeId;
var (_, errors) = InvokeHistoryRead(server, nm, new ReadProcessedDetails
{
StartTime = DateTime.UtcNow.AddHours(-1), EndTime = DateTime.UtcNow, ProcessingInterval = 10_000.0,
AggregateType = new NodeIdCollection { ObjectIds.AggregateFunction_PercentGood },
}, nodeId);
errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadAggregateNotSupported);
await host.DisposeAsync();
}
/// <summary>#671: a read past a backend size limit (the time-weighted raw-row cap) answers BadTooManyOperations
/// for that node (not the generic BadHistoryOperationUnsupported, nor a deadline BadTimeout).</summary>
[Fact]
public async Task Processed_limit_exceeded_yields_BadTooManyOperations()
{
var (host, server) = await BootAsync();
var nm = server.NodeManager!;
nm.HistorianDataSource = new RecordingHistorianDataSource { LimitExceeded = true };
nm.EnsureVariable("eq-1/cap", parentFolderNodeId: null, displayName: "C", dataType: "Float",
writable: false, historianTagname: "WW.Cap", realm: AddressSpaceRealm.Uns);
var nodeId = nm.TryGetVariable("eq-1/cap")!.NodeId;
var (_, errors) = InvokeHistoryRead(server, nm, new ReadProcessedDetails
{
StartTime = DateTime.UtcNow.AddHours(-1), EndTime = DateTime.UtcNow, ProcessingInterval = 10_000.0,
AggregateType = new NodeIdCollection { ObjectIds.AggregateFunction_Total },
}, nodeId);
errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadTooManyOperations);
await host.DisposeAsync();
}
/// <summary>AtTime read: the requested timestamps reach ReadAtTimeAsync in order.</summary>
[Fact]
public async Task AtTime_dispatches_requested_timestamps()
{
var (host, server) = await BootAsync();
var nm = server.NodeManager!;
var fake = new RecordingHistorianDataSource { AtTimeResult = Empty() };
nm.HistorianDataSource = fake;
nm.EnsureVariable("eq-1/at", parentFolderNodeId: null, displayName: "At", dataType: "Float",
writable: false, historianTagname: "WW.At", realm: AddressSpaceRealm.Uns);
var nodeId = nm.TryGetVariable("eq-1/at")!.NodeId;
var t1 = DateTime.UtcNow.AddMinutes(-2);
var t2 = DateTime.UtcNow.AddMinutes(-1);
var details = new ReadAtTimeDetails
{
ReqTimes = new DateTimeCollection { t1, t2 },
};
InvokeHistoryRead(server, nm, details, nodeId);
fake.LastCall.ShouldBe("AtTime");
fake.LastTagname.ShouldBe("WW.At");
fake.LastTimestamps.ShouldBe(new[] { t1, t2 });
await host.DisposeAsync();
}
/// <summary>
/// Two nodes in a single Raw HistoryRead batch — one backend succeeds, one throws. Proves that
/// per-node error isolation is enforced across handle indices: the good node's slot is
/// Good+samples, the bad node's slot is Bad, and neither status leaks into the other.
/// </summary>
[Fact]
public async Task Raw_multi_node_per_node_error_isolation()
{
var (host, server) = await BootAsync();
var nm = server.NodeManager!;
// Materialise two distinct historized variables under separate equipment folders.
nm.EnsureVariable("eqA/good", parentFolderNodeId: null, displayName: "Good", dataType: "Float",
writable: false, historianTagname: "A.PV", realm: AddressSpaceRealm.Uns);
nm.EnsureVariable("eqB/bad", parentFolderNodeId: null, displayName: "Bad", dataType: "Float",
writable: false, historianTagname: "B.PV", realm: AddressSpaceRealm.Uns);
var goodNodeId = nm.TryGetVariable("eqA/good")!.NodeId;
var badNodeId = nm.TryGetVariable("eqB/bad")!.NodeId;
var src = DateTime.UtcNow.AddSeconds(-5);
var srv = DateTime.UtcNow;
// Per-tagname fake: A.PV returns one sample; B.PV throws.
var perTagFake = new PerTagnameHistorianDataSource(tagname =>
tagname == "A.PV"
? Task.FromResult(new HistorianRead(new[] { new DataValueSnapshot(1.0f, StatusCodes.Good, src, srv) }, null))
: throw new InvalidOperationException("backend boom for B.PV"));
nm.HistorianDataSource = perTagFake;
var details = new ReadRawModifiedDetails
{
ReturnBounds = false,
StartTime = DateTime.UtcNow.AddHours(-1),
EndTime = DateTime.UtcNow,
NumValuesPerNode = 10,
IsReadModified = false,
};
// Single batch listing BOTH nodes in order [goodNodeId, badNodeId].
// handle.Index maps by position: good=0, bad=1.
var (results, errors) = InvokeHistoryRead(server, nm, details, goodNodeId, badNodeId);
// Good node (index 0): should have a Good status + one sample.
errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good);
results[0].StatusCode.Code.ShouldBe(StatusCodes.Good);
var data = (HistoryData)ExtensionObject.ToEncodeable(results[0].HistoryData);
data.DataValues.Count.ShouldBe(1);
data.DataValues[0].Value.ShouldBe(1.0f);
// Bad node (index 1): errors slot must be Bad; results slot must NOT carry any HistoryData
// (no data was projected into it). The base may pre-seed results[i] to a default-constructed
// SdkHistoryReadResult whose StatusCode is 0 (Good) — that's fine for the result field, but the
// distinguishing signal is the error slot AND the absence of HistoryData from the good node.
StatusCode.IsBad(errors[1].StatusCode).ShouldBeTrue();
errors[1].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported);
// HistoryData must be null/empty — the good node's sample MUST NOT have leaked into slot 1.
(results[1].HistoryData == null || ExtensionObject.IsNull(results[1].HistoryData)).ShouldBeTrue();
await host.DisposeAsync();
}
/// <summary>A backend that throws ⇒ that node's error is Bad (not GoodNoData) and no exception
/// escapes the HistoryRead call.</summary>
[Fact]
public async Task Backend_throw_yields_bad_status_and_does_not_escape()
{
var (host, server) = await BootAsync();
var nm = server.NodeManager!;
var fake = new RecordingHistorianDataSource { ThrowOnRead = true };
nm.HistorianDataSource = fake;
nm.EnsureVariable("eq-1/boom", parentFolderNodeId: null, displayName: "Boom", dataType: "Float",
writable: false, historianTagname: "WW.Boom", realm: AddressSpaceRealm.Uns);
var nodeId = nm.TryGetVariable("eq-1/boom")!.NodeId;
var details = new ReadRawModifiedDetails
{
ReturnBounds = false,
StartTime = DateTime.UtcNow.AddHours(-1),
EndTime = DateTime.UtcNow,
NumValuesPerNode = 10,
IsReadModified = false,
};
// The call must not throw even though the backend does.
var (results, errors) = InvokeHistoryRead(server, nm, details, nodeId);
StatusCode.IsBad(errors[0].StatusCode).ShouldBeTrue();
errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported);
// The result slot was never filled with a GoodNoData success.
(results[0].StatusCode.Code == StatusCodes.GoodNoData).ShouldBeFalse();
await host.DisposeAsync();
}
/// <summary>Invoke the node manager's public HistoryRead with a single node, returning the filled
/// results + errors. Uses a session-less <see cref="OperationContext"/> (the
/// (RequestHeader, SecureChannelContext, RequestType, IUserIdentity) ctor) — HistoryRead's
/// handle-building only needs the NodeId + namespace, not a session.</summary>
private static (IList<SdkHistoryReadResult> Results, IList<ServiceResult> Errors) InvokeHistoryRead(
OtOpcUaSdkServer server, OtOpcUaNodeManager nm, HistoryReadDetails details, NodeId nodeId)
=> InvokeHistoryRead(server, nm, details, new[] { nodeId });
/// <summary>Multi-node overload — issues one HistoryRead batch for all supplied node ids in order.
/// Results and errors are returned in the same order as <paramref name="nodeIds"/>.</summary>
private static (IList<SdkHistoryReadResult> Results, IList<ServiceResult> Errors) InvokeHistoryRead(
OtOpcUaSdkServer server, OtOpcUaNodeManager nm, HistoryReadDetails details, params NodeId[] nodeIds)
{
var context = new OperationContext(
new RequestHeader(), secureChannelContext: null, RequestType.HistoryRead, identity: null);
var nodesToRead = nodeIds.Select(id => new HistoryReadValueId { NodeId = id }).ToList();
var results = Enumerable.Repeat<SdkHistoryReadResult>(null!, nodeIds.Length).ToList();
var errors = Enumerable.Repeat<ServiceResult>(null!, nodeIds.Length).ToList();
nm.HistoryRead(
context,
details,
TimestampsToReturn.Both,
releaseContinuationPoints: false,
nodesToRead,
results,
errors);
return (results, errors);
}
private static HistorianRead Empty() => new(Array.Empty<DataValueSnapshot>(), null);
/// <summary>A recording fake historian source — captures the last call's kind + arguments and returns
/// a configured result (or throws when <see cref="ThrowOnRead"/> is set).</summary>
private sealed class RecordingHistorianDataSource : IHistorianDataSource
{
public bool ThrowOnRead { get; init; }
public bool AggregateNotSupported { get; init; }
public bool LimitExceeded { get; init; }
public IReadOnlySet<HistoryAggregateType>? Supported { get; init; } = HistoryAggregateTypes.All;
public IReadOnlySet<HistoryAggregateType> SupportedAggregates => Supported!;
public HistorianRead RawResult { get; set; } = new(Array.Empty<DataValueSnapshot>(), null);
public HistorianRead ProcessedResult { get; set; } = new(Array.Empty<DataValueSnapshot>(), null);
public HistorianRead AtTimeResult { get; set; } = new(Array.Empty<DataValueSnapshot>(), null);
public string? LastCall { get; private set; }
public string? LastTagname { get; private set; }
public DateTime LastStart { get; private set; }
public DateTime LastEnd { get; private set; }
public uint LastMaxValues { get; private set; }
public TimeSpan LastInterval { get; private set; }
public HistoryAggregateType LastAggregate { get; private set; }
public IReadOnlyList<DateTime>? LastTimestamps { get; private set; }
public Task<HistorianRead> ReadRawAsync(
string fullReference, DateTime startUtc, DateTime endUtc, uint maxValuesPerNode,
CancellationToken cancellationToken)
{
if (ThrowOnRead) throw new InvalidOperationException("backend boom");
LastCall = "Raw";
LastTagname = fullReference;
LastStart = startUtc;
LastEnd = endUtc;
LastMaxValues = maxValuesPerNode;
return Task.FromResult(RawResult);
}
public Task<HistorianRead> ReadProcessedAsync(
string fullReference, DateTime startUtc, DateTime endUtc, TimeSpan interval,
HistoryAggregateType aggregate, CancellationToken cancellationToken)
{
if (ThrowOnRead) throw new InvalidOperationException("backend boom");
if (AggregateNotSupported) throw new HistoryAggregateNotSupportedException(aggregate);
if (LimitExceeded) throw new HistoryReadLimitExceededException(4, "too many raw rows");
LastCall = "Processed";
LastTagname = fullReference;
LastStart = startUtc;
LastEnd = endUtc;
LastInterval = interval;
LastAggregate = aggregate;
return Task.FromResult(ProcessedResult);
}
public Task<HistorianRead> ReadAtTimeAsync(
string fullReference, IReadOnlyList<DateTime> timestampsUtc, CancellationToken cancellationToken)
{
if (ThrowOnRead) throw new InvalidOperationException("backend boom");
LastCall = "AtTime";
LastTagname = fullReference;
LastTimestamps = timestampsUtc;
return Task.FromResult(AtTimeResult);
}
public Task<HistoricalEventsResult> ReadEventsAsync(
string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents,
CancellationToken cancellationToken) =>
Task.FromResult(new HistoricalEventsResult(Array.Empty<HistoricalEvent>(), null));
public HistorianHealthSnapshot GetHealthSnapshot() => NullHistorianDataSource.Instance.GetHealthSnapshot();
public void Dispose()
{
}
}
/// <summary>A per-tagname historian source that delegates each raw read to a caller-supplied
/// <see cref="Func{T, TResult}"/>. The func may throw to simulate a backend failure for a
/// specific tagname while returning results for others — enabling per-node isolation tests.
/// Processed / AtTime / Events are not wired (they delegate to the null source).</summary>
private sealed class PerTagnameHistorianDataSource(Func<string, Task<HistorianRead>> rawHandler)
: IHistorianDataSource
{
public Task<HistorianRead> ReadRawAsync(
string fullReference, DateTime startUtc, DateTime endUtc, uint maxValuesPerNode,
CancellationToken cancellationToken) => rawHandler(fullReference);
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) =>
NullHistorianDataSource.Instance.ReadEventsAsync(sourceName, startUtc, endUtc, maxEvents, cancellationToken);
public HistorianHealthSnapshot GetHealthSnapshot() => NullHistorianDataSource.Instance.GetHealthSnapshot();
public void Dispose() { }
}
private async Task<(OpcUaApplicationHost Host, OtOpcUaSdkServer Server)> BootAsync()
{
var applicationUri = $"urn:OtOpcUa.HistoryReadTest:{Guid.NewGuid():N}";
var started = await OpcUaHostStart.StartAsync(
port => new OpcUaApplicationHost(
new OpcUaApplicationHostOptions
{
ApplicationName = "OtOpcUa.HistoryReadTest",
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 */ }
}
}
}