Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadTests.cs
T
Joseph Doherty ee39c169df
v2-ci / unit-tests (push) Has been cancelled
fix(historian): refuse aggregates not valid for the node's DataType up front (#897)
TimeAverage / Total on a Boolean node over the Gateway backend (AVEVA discrete tag, live
SysPulse) went to the gateway since native TimeAverage/Total became default-on (#694). AVEVA
refuses both modes at StartQuery; the gateway reports that only as Internal /
HistorianServerOperationException (the same answer as a historian storage fault), so the
node answered BadHistoryOperationUnsupported and the read counted as a backend failure.

HistoryRead-Processed now checks the node's scalar DataType before calling any backend and
answers BadAggregateNotSupported (Part 11 6.5.4: "If the Aggregate is not valid for the Node
then the StatusCode shall be Bad_AggregateNotSupported"). Nothing is sent, nothing is counted.
- Shared, every backend: HistoryAggregateDataTypes (Core.Abstractions) applies Part 13
  5.4.2.3 Table 50: the numeric-only aggregates (Average, TimeAverage(2), Total(2), Min/Max,
  ActualTime, Range, Delta, DeltaBounds, Interpolative, stddev/variance) on a String or
  DateTime node are refused. Client-visible: the SQL backends used to answer every interval
  BadAggregateInvalidInputs.
- Per backend: IHistorianDataSource.SupportsAggregateFor(aggregate, TypeCode?) (default true;
  every production backend declares it, guard-tested). Gateway: false for native
  TimeAverage/Total on a Boolean node. SqlServer/QuestDb: true, since they serve Boolean as
  0/1 (stepped), so Boolean stays served there, as do the Gateway's raw-evaluated aggregates.

The residual case (a node declared numeric whose AVEVA tag is discrete) cannot be told apart
from a historian fault on the wire and still counts; that needs the gateway to surface
AVEVA's native error code as a deterministic status.

Tests: HistoryAggregateDataTypesTests, guard test, node-manager String/DateTime and per-type
refusals, Gateway capability matrix, DiscreteTagTimeWeightedWireTests (production Gateway
backend over an AVEVA-shaped fake: unsent, 0 health failures, positive controls). Each
mutation (no check, no shared rule, no backend hint, Gateway hint true, shared rule always
true, SQL inheriting the default) fails at least one. Live gate re-run read-only against
AVEVA 2023 R2 via a private HistorianGateway fa9cf31 container: SysPulse TimeAverage on the
wire = BadAggregateNotSupported, health failures unchanged; #801 gate 244 cases pass.

Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
2026-11-13 00:41:33 -05:00

1226 lines
63 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.Code, 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.Code);
results[0].StatusCode.Code.ShouldBe(StatusCodes.Good.Code);
var data = (HistoryData)ExtensionObject.ToEncodeable(results[0].HistoryData)!;
data.DataValues.Count.ShouldBe(1);
var dv = data.DataValues[0];
SdkValueBoundary.ToClr(dv.WrappedValue)!.ShouldBe(42.5f);
dv.StatusCode.Code.ShouldBe(StatusCodes.Good.Code);
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.Code);
results[0].StatusCode.Code.ShouldBe(StatusCodes.GoodNoData.Code);
var data = (HistoryData)ExtensionObject.ToEncodeable(results[0].HistoryData)!;
data.DataValues.Count.ShouldBe(0);
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.Code);
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.Code);
results[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported.Code);
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.Code);
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
AggregateConfiguration = new AggregateConfiguration { UseServerCapabilitiesDefaults = true },
AggregateType = new List<NodeId> { 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.
AggregateConfiguration = new AggregateConfiguration { UseServerCapabilitiesDefaults = true },
AggregateType = new List<NodeId> { ObjectIds.AggregateFunction_StandardDeviationSample },
};
var (_, errors) = InvokeHistoryRead(server, nm, details, nodeId);
errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadAggregateNotSupported.Code);
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,
AggregateConfiguration = new AggregateConfiguration { UseServerCapabilitiesDefaults = true },
AggregateType = new List<NodeId> { 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.Code);
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.Code);
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,
AggregateConfiguration = new AggregateConfiguration { UseServerCapabilitiesDefaults = true },
AggregateType = new List<NodeId> { 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 = (AsyncCustomNodeManager)nm.Server.DiagnosticsNodeManager;
var folder = diagnostics.FindPredefinedNode<NodeState>(ObjectIds.HistoryServerCapabilities_AggregateFunctions)!;
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 List<NodeId> { ObjectIds.AggregateFunction_TimeAverage },
AggregateConfiguration = new AggregateConfiguration
{
UseServerCapabilitiesDefaults = false, TreatUncertainAsBad = treatUncertainAsBad,
PercentDataBad = percentBad, PercentDataGood = percentGood, UseSlopedExtrapolation = sloped,
},
}, nodeId);
errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadAggregateConfigurationRejected.Code);
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 List<NodeId> { 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 / #782: our declared aggregate defaults are pinned, and we DELIBERATELY diverge from the SDK on
/// TreatUncertainAsBad (SDK 1.5.378.182 default is true; Part 13 and our evaluators say false). The SDK-default
/// assertions are a tripwire: if a future SDK changes them, this fails and the divergence is re-decided.
/// </summary>
[Fact]
public void The_server_aggregate_defaults_deliberately_diverge_from_the_SDK_on_TreatUncertainAsBad()
{
var sdk = new Opc.Ua.Server.AggregateManager(null!).GetDefaultConfiguration(NodeId.Null);
var ours = ServerAggregateConfiguration.Defaults;
ours.TreatUncertainAsBad.ShouldBeFalse();
ours.PercentDataBad.ShouldBe((byte)100);
ours.PercentDataGood.ShouldBe((byte)100);
ours.UseSlopedExtrapolation.ShouldBeFalse();
// What we believe the SDK default is (1.5.378.182): TreatUncertainAsBad flipped to true.
sdk.TreatUncertainAsBad.ShouldBeTrue("the SDK default changed - re-decide the deliberate divergence");
ours.PercentDataBad.ShouldBe(sdk.PercentDataBad);
ours.PercentDataGood.ShouldBe(sdk.PercentDataGood);
ours.UseSlopedExtrapolation.ShouldBe(sdk.UseSlopedExtrapolation);
ServerAggregateConfiguration.IsServable(ours).ShouldBeTrue();
ServerAggregateConfiguration.IsServable(sdk).ShouldBeFalse("the SDK default is not what we serve");
ServerAggregateConfiguration.IsServable(null).ShouldBeTrue();
}
/// <summary>#782: the booted SDK server's AggregateManager (live AggregateFilter path) runs under OUR defaults.</summary>
[Fact]
public async Task The_booted_SDK_AggregateManager_default_is_our_TreatUncertainAsBad_false()
{
var (host, server) = await BootAsync();
var cfg = server.CurrentInstance.AggregateManager.GetDefaultConfiguration(NodeId.Null);
cfg.TreatUncertainAsBad.ShouldBeFalse();
cfg.PercentDataBad.ShouldBe(ServerAggregateConfiguration.PercentDataBad);
cfg.PercentDataGood.ShouldBe(ServerAggregateConfiguration.PercentDataGood);
cfg.UseSlopedExtrapolation.ShouldBeFalse();
await host.DisposeAsync();
}
/// <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,
AggregateConfiguration = new AggregateConfiguration { UseServerCapabilitiesDefaults = true },
AggregateType = new List<NodeId> { 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,
AggregateConfiguration = new AggregateConfiguration { UseServerCapabilitiesDefaults = true },
AggregateType = new List<NodeId> { 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,
AggregateConfiguration = new AggregateConfiguration { UseServerCapabilitiesDefaults = true },
AggregateType = new List<NodeId> { ObjectIds.AggregateFunction_PercentGood },
}, nodeId);
errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadAggregateNotSupported.Code);
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,
AggregateConfiguration = new AggregateConfiguration { UseServerCapabilitiesDefaults = true },
AggregateType = new List<NodeId> { ObjectIds.AggregateFunction_Total },
}, nodeId);
errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadTooManyOperations.Code);
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 List<DateTimeUtc> { 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>
/// #693: AtTime passes each node's scalar value type to the source (the Gateway needs it — its stored values carry no
/// type), through a Raw NodeId and a UNS NodeId alike; an array node passes none. (The fake above implements only the
/// hint-less overload, and <see cref="AtTime_dispatches_requested_timestamps"/> proves the interface default forwards.)
/// </summary>
[Fact]
public async Task AtTime_passes_the_nodes_value_type_for_Raw_and_UNS_nodes()
{
var (host, server) = await BootAsync();
var nm = server.NodeManager!;
var fake = new HintRecordingSource();
nm.HistorianDataSource = fake;
nm.EnsureVariable("Plant/Modbus/dev1/Flag", parentFolderNodeId: null, displayName: "Flag", dataType: "Boolean",
writable: false, historianTagname: "R.Flag", realm: AddressSpaceRealm.Raw);
nm.EnsureVariable("Area/Line/Eq/Flag", parentFolderNodeId: null, displayName: "Flag", dataType: "Boolean",
writable: false, historianTagname: "U.Flag", realm: AddressSpaceRealm.Uns);
nm.EnsureVariable("Plant/Modbus/dev1/Count", parentFolderNodeId: null, displayName: "Count", dataType: "Int32",
writable: false, historianTagname: "R.Count", realm: AddressSpaceRealm.Raw);
nm.EnsureVariable("Area/Line/Eq/Flow", parentFolderNodeId: null, displayName: "Flow", dataType: "Double",
writable: false, historianTagname: "U.Flow", realm: AddressSpaceRealm.Uns);
nm.EnsureVariable("Plant/Modbus/dev1/Arr", parentFolderNodeId: null, displayName: "Arr", dataType: "Int16",
writable: false, historianTagname: "R.Arr", realm: AddressSpaceRealm.Raw, isArray: true, arrayLength: 4);
string[] paths = ["Plant/Modbus/dev1/Flag", "Area/Line/Eq/Flag", "Plant/Modbus/dev1/Count", "Area/Line/Eq/Flow", "Plant/Modbus/dev1/Arr"];
AddressSpaceRealm[] realms = [AddressSpaceRealm.Raw, AddressSpaceRealm.Uns, AddressSpaceRealm.Raw, AddressSpaceRealm.Uns, AddressSpaceRealm.Raw];
var nodeIds = paths.Select((p, i) => nm.TryGetVariable(p, realms[i])!.NodeId).ToArray();
var (_, errors) = InvokeHistoryRead(server, nm,
new ReadAtTimeDetails { ReqTimes = new List<DateTimeUtc> { DateTime.UtcNow.AddMinutes(-1) } }, nodeIds);
errors.ShouldAllBe(e => StatusCode.IsGood(e.StatusCode));
fake.Hints["R.Flag"].ShouldBe(TypeCode.Boolean);
fake.Hints["U.Flag"].ShouldBe(TypeCode.Boolean);
fake.Hints["R.Count"].ShouldBe(TypeCode.Int32);
fake.Hints["U.Flow"].ShouldBe(TypeCode.Double);
fake.Hints["R.Arr"].ShouldBeNull();
await host.DisposeAsync();
}
/// <summary>A source recording the AtTime value-type hint per tagname (the hinted overload only).</summary>
private sealed class HintRecordingSource : IHistorianDataSource
{
public System.Collections.Concurrent.ConcurrentDictionary<string, TypeCode?> Hints { get; } = new();
public Task<HistorianRead> ReadAtTimeAsync(
string fullReference, IReadOnlyList<DateTime> timestampsUtc, TypeCode? valueType, CancellationToken cancellationToken)
{
Hints[fullReference] = valueType;
return Task.FromResult(Empty());
}
public Task<HistorianRead> ReadAtTimeAsync(
string fullReference, IReadOnlyList<DateTime> timestampsUtc, CancellationToken cancellationToken) =>
throw new InvalidOperationException("the node manager must call the hinted overload");
public Task<HistorianRead> ReadRawAsync(string fullReference, DateTime startUtc, DateTime endUtc, uint maxValuesPerNode,
CancellationToken cancellationToken) => Task.FromResult(Empty());
public Task<HistorianRead> ReadProcessedAsync(string fullReference, DateTime startUtc, DateTime endUtc, TimeSpan interval,
HistoryAggregateType aggregate, CancellationToken cancellationToken) => Task.FromResult(Empty());
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>
/// 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.Code, 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.Code);
results[0].StatusCode.Code.ShouldBe(StatusCodes.Good.Code);
var data = (HistoryData)ExtensionObject.ToEncodeable(results[0].HistoryData)!;
data.DataValues.Count.ShouldBe(1);
SdkValueBoundary.ToClr(data.DataValues[0].WrappedValue)!.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.Code);
// HistoryData must be null/empty — the good node's sample MUST NOT have leaked into slot 1.
results[1].HistoryData.IsNull.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.Code);
// The result slot was never filled with a GoodNoData success.
(results[0].StatusCode.Code == StatusCodes.GoodNoData).ShouldBeFalse();
await host.DisposeAsync();
}
[Fact]
public async Task Canceling_the_SDK_request_cancels_an_inflight_backend_read()
{
var (host, server) = await BootAsync();
await using var ownedHost = host;
var nm = server.NodeManager!;
nm.HistoryReadDeadline = TimeSpan.Zero;
var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
nm.HistorianDataSource = new RecordingHistorianDataSource
{
RawRead = async ct =>
{
entered.SetResult();
await Task.Delay(Timeout.InfiniteTimeSpan, ct);
return Empty();
},
};
nm.EnsureVariable("cancel", null, "Cancel", "Double", false, AddressSpaceRealm.Raw, "cancel.tag");
var node = nm.TryGetVariable("cancel", AddressSpaceRealm.Raw)!.NodeId;
using var lifetime = new RequestLifetime();
using var context = new OperationContext(new RequestHeader(), null, RequestType.HistoryRead, lifetime);
var results = new List<SdkHistoryReadResult> { null! };
var errors = new List<ServiceResult> { null! };
var read = Task.Run(() => nm.HistoryRead(context, new ReadRawModifiedDetails
{
StartTime = DateTime.UtcNow.AddHours(-1), EndTime = DateTime.UtcNow, NumValuesPerNode = 10,
}, TimestampsToReturn.Both, false, [new HistoryReadValueId { NodeId = node }], results, errors), Ct);
await entered.Task.WaitAsync(TimeSpan.FromSeconds(5), Ct);
lifetime.TryCancel(StatusCodes.BadRequestCancelledByClient).ShouldBeTrue();
await read.WaitAsync(TimeSpan.FromSeconds(5), Ct);
StatusCode.IsBad(errors[0].StatusCode).ShouldBeTrue();
results[0].ContinuationPoint.IsEmpty.ShouldBeTrue();
}
/// <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, requestLifetime: RequestLifetime.None, 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);
}
[Theory]
[InlineData("Raw")]
[InlineData("AtTime")]
[InlineData("Processed")]
public async Task An_unreachable_backend_answers_a_communication_status(string kind)
{
var (host, server) = await BootAsync();
try
{
var nm = server.NodeManager!;
nm.HistorianDataSource = new RecordingHistorianDataSource { ReadException = new System.IO.IOException("connection lost") };
nm.EnsureVariable("eq/comm", null, "Comm", "Double", false, historianTagname: "T", realm: AddressSpaceRealm.Uns);
var t = DateTime.UtcNow.AddMinutes(-1);
HistoryReadDetails details = kind switch
{
"Raw" => new ReadRawModifiedDetails { IsReadModified = false, ReturnBounds = false, StartTime = t, EndTime = t.AddSeconds(10) },
"AtTime" => new ReadAtTimeDetails { ReqTimes = new List<DateTimeUtc> { t } },
_ => new ReadProcessedDetails { StartTime = t, EndTime = t.AddSeconds(10), ProcessingInterval = 1000,
AggregateConfiguration = new AggregateConfiguration { UseServerCapabilitiesDefaults = true },
AggregateType = new List<NodeId> { ObjectIds.AggregateFunction_Average } }
};
var (results, errors) = InvokeHistoryRead(server, nm, details, nm.TryGetVariable("eq/comm")!.NodeId);
errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadNoCommunication.Code);
results[0].StatusCode.Code.ShouldBe(StatusCodes.BadNoCommunication.Code);
}
finally { await host.DisposeAsync(); }
}
[Theory]
[InlineData("Int32", "Raw")]
[InlineData("Boolean", "Raw")]
[InlineData("Float", "Raw")]
[InlineData("Int32", "AtTime")]
[InlineData("Boolean", "AtTime")]
[InlineData("Float", "AtTime")]
public async Task History_values_use_the_nodes_scalar_type(string dataType, string kind)
{
var (host, server) = await BootAsync();
try
{
var nm = server.NodeManager!;
var t = DateTime.UtcNow.AddMinutes(-1);
var value = new HistorianRead([new DataValueSnapshot(1d, StatusCodes.Good.Code, t, t)], null);
nm.HistorianDataSource = new RecordingHistorianDataSource { RawResult = value, AtTimeResult = value };
nm.EnsureVariable("eq/typed", null, "Typed", dataType, false, historianTagname: "T", realm: AddressSpaceRealm.Uns);
HistoryReadDetails details = kind == "Raw"
? new ReadRawModifiedDetails { IsReadModified = false, ReturnBounds = false, StartTime = t, EndTime = t.AddSeconds(10) }
: new ReadAtTimeDetails { ReqTimes = new List<DateTimeUtc> { t } };
var (results, errors) = InvokeHistoryRead(server, nm, details, nm.TryGetVariable("eq/typed")!.NodeId);
errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good.Code);
var actual = ((HistoryData)ExtensionObject.ToEncodeable(results[0].HistoryData)!).DataValues.ToArray()!.Single();
if (dataType == "Int32") SdkValueBoundary.ToClr(actual.WrappedValue)!.ShouldBeOfType<int>().ShouldBe(1);
if (dataType == "Boolean") SdkValueBoundary.ToClr(actual.WrappedValue)!.ShouldBeOfType<bool>().ShouldBeTrue();
if (dataType == "Float") SdkValueBoundary.ToClr(actual.WrappedValue)!.ShouldBeOfType<float>().ShouldBe(1f);
actual.SourceTimestamp.ShouldBe(t);
actual.StatusCode.Code.ShouldBe(StatusCodes.Good.Code);
}
finally { await host.DisposeAsync(); }
}
[Theory]
[InlineData("Int16", 40000d)]
[InlineData("DateTime", 1d)]
public async Task An_unrepresentable_historical_value_is_null_with_BadTypeMismatch(string type, double input)
{
var (host, server) = await BootAsync();
try
{
var nm = server.NodeManager!;
var t = DateTime.UtcNow.AddMinutes(-1);
nm.EnsureVariable("eq/mismatch", null, "Mismatch", type, false, historianTagname: "T", realm: AddressSpaceRealm.Uns);
nm.HistorianDataSource = new RecordingHistorianDataSource { RawResult = new HistorianRead([new(input, StatusCodes.Good.Code, t, t)], null) };
var (results, _) = InvokeHistoryRead(server, nm, new ReadRawModifiedDetails { IsReadModified = false, ReturnBounds = false,
StartTime = t, EndTime = t.AddSeconds(10) }, nm.TryGetVariable("eq/mismatch")!.NodeId);
var value = ((HistoryData)ExtensionObject.ToEncodeable(results[0].HistoryData)!).DataValues.ToArray()!.Single();
SdkValueBoundary.ToClr(value.WrappedValue)!.ShouldBeNull();
value.StatusCode.Code.ShouldBe(StatusCodes.BadTypeMismatch.Code);
value.SourceTimestamp.ShouldBe(t);
value.ServerTimestamp.ShouldBe(t);
}
finally { await host.DisposeAsync(); }
}
[Fact]
public async Task Array_and_null_history_values_preserve_quality_and_timestamps()
{
var (host, server) = await BootAsync();
try
{
var nm = server.NodeManager!;
var t = DateTime.UtcNow.AddMinutes(-1);
var serverTime = t.AddSeconds(1);
nm.EnsureVariable("eq/array", null, "Array", "Int32", false, historianTagname: "T", realm: AddressSpaceRealm.Raw, isArray: true, arrayLength: 2);
nm.HistorianDataSource = new RecordingHistorianDataSource { RawResult = new HistorianRead([
new(new int[] { 1, 2 }, StatusCodes.Uncertain.Code, t, serverTime),
new(null, StatusCodes.BadNoCommunication.Code, t.AddSeconds(1), serverTime)], null) };
var (results, _) = InvokeHistoryRead(server, nm, new ReadRawModifiedDetails { IsReadModified = false, ReturnBounds = false,
StartTime = t, EndTime = t.AddSeconds(10) }, nm.TryGetVariable("eq/array", AddressSpaceRealm.Raw)!.NodeId);
var values = ((HistoryData)ExtensionObject.ToEncodeable(results[0].HistoryData)!).DataValues;
SdkValueBoundary.ToClr(values[0].WrappedValue)!.ShouldBeOfType<int[]>().ShouldBe([1, 2]);
values[0].StatusCode.Code.ShouldBe(StatusCodes.Uncertain.Code);
values[0].SourceTimestamp.ShouldBe(t);
values[0].ServerTimestamp.ShouldBe(serverTime);
SdkValueBoundary.ToClr(values[1].WrappedValue)!.ShouldBeNull();
values[1].StatusCode.Code.ShouldBe(StatusCodes.BadNoCommunication.Code);
}
finally { await host.DisposeAsync(); }
}
[Fact]
public async Task Processed_Count_keeps_its_aggregate_type_on_a_Boolean_node()
{
var (host, server) = await BootAsync();
try
{
var nm = server.NodeManager!;
var t = DateTime.UtcNow.AddMinutes(-1);
nm.EnsureVariable("eq/count", null, "Count", "Boolean", false, historianTagname: "T", realm: AddressSpaceRealm.Uns);
nm.HistorianDataSource = new RecordingHistorianDataSource { ProcessedResult = new HistorianRead([new(3d, StatusCodes.Good.Code, t, t)], null) };
var (results, _) = InvokeHistoryRead(server, nm, new ReadProcessedDetails { StartTime = t, EndTime = t.AddSeconds(10),
ProcessingInterval = 1000, AggregateConfiguration = new AggregateConfiguration { UseServerCapabilitiesDefaults = true },
AggregateType = new List<NodeId> { ObjectIds.AggregateFunction_Count } }, nm.TryGetVariable("eq/count")!.NodeId);
var value = ((HistoryData)ExtensionObject.ToEncodeable(results[0].HistoryData)!).DataValues.ToArray()!.Single();
SdkValueBoundary.ToClr(value.WrappedValue)!.ShouldBeOfType<double>().ShouldBe(3d);
}
finally { await host.DisposeAsync(); }
}
private static ReadProcessedDetails ProcessedDetails(NodeId aggregate) => new()
{
StartTime = DateTime.UtcNow.AddHours(-1),
EndTime = DateTime.UtcNow,
ProcessingInterval = 10_000.0,
AggregateConfiguration = new AggregateConfiguration { UseServerCapabilitiesDefaults = true },
AggregateType = new List<NodeId> { aggregate },
};
/// <summary>
/// lmxopcua#897 — Part 11 §6.5.4 ("If the Aggregate is not valid for the Node then the StatusCode shall be
/// Bad_AggregateNotSupported") with Part 13 Table 50 (TimeAverage, Total, Average, … are numeric only): a String or
/// DateTime node asked for a numeric aggregate is refused up front on EVERY backend, without a backend call, while
/// an all-types aggregate (Count) on the same node and the numeric one on a numeric node still reach the backend.
/// </summary>
[Theory]
[InlineData("String", "TimeAverage")]
[InlineData("String", "Total")]
[InlineData("String", "Average")]
[InlineData("String", "Maximum")]
[InlineData("DateTime", "TimeAverage")]
[InlineData("DateTime", "Delta")]
public async Task A_numeric_aggregate_on_a_non_numeric_node_is_refused_unsent(string dataType, string aggregate)
{
var (host, server) = await BootAsync();
try
{
var nm = server.NodeManager!;
var fake = new RecordingHistorianDataSource { ProcessedResult = Empty() };
nm.HistorianDataSource = fake;
nm.EnsureVariable("eq/text", null, "Text", dataType, false, historianTagname: "T.Text", realm: AddressSpaceRealm.Uns);
nm.EnsureVariable("eq/num", null, "Num", "Double", false, historianTagname: "T.Num", realm: AddressSpaceRealm.Uns);
var aggregateId = aggregate switch
{
"TimeAverage" => ObjectIds.AggregateFunction_TimeAverage,
"Total" => ObjectIds.AggregateFunction_Total,
"Average" => ObjectIds.AggregateFunction_Average,
"Maximum" => ObjectIds.AggregateFunction_Maximum,
_ => ObjectIds.AggregateFunction_Delta,
};
var (_, errors) = InvokeHistoryRead(server, nm, ProcessedDetails(aggregateId), nm.TryGetVariable("eq/text")!.NodeId);
errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadAggregateNotSupported.Code);
fake.LastCall.ShouldBeNull("the refusal is decided before any backend read");
// Positive controls: an all-types aggregate on the same node, and the same aggregate on a numeric node.
InvokeHistoryRead(server, nm, ProcessedDetails(ObjectIds.AggregateFunction_Count), nm.TryGetVariable("eq/text")!.NodeId)
.Item2[0].StatusCode.Code.ShouldBe(StatusCodes.Good.Code);
fake.LastCall.ShouldBe("Processed");
fake.LastAggregate.ShouldBe(HistoryAggregateType.Count);
InvokeHistoryRead(server, nm, ProcessedDetails(aggregateId), nm.TryGetVariable("eq/num")!.NodeId)
.Item2[0].StatusCode.Code.ShouldBe(StatusCodes.Good.Code);
fake.LastTagname.ShouldBe("T.Num");
}
finally { await host.DisposeAsync(); }
}
/// <summary>
/// lmxopcua#897: a backend's own per-type limit (<see cref="IHistorianDataSource.SupportsAggregateFor"/> — the
/// Gateway refuses native TimeAverage / Total on a Boolean node) is consulted with the node's scalar type and answers
/// <c>BadAggregateNotSupported</c> without a backend read; the same aggregate on another type, and another aggregate
/// on the Boolean node, are served.
/// </summary>
[Fact]
public async Task A_backend_per_type_refusal_answers_BadAggregateNotSupported_unsent()
{
var (host, server) = await BootAsync();
try
{
var nm = server.NodeManager!;
var fake = new RecordingHistorianDataSource
{
ProcessedResult = Empty(),
SupportsFor = (a, t) => !(t == TypeCode.Boolean && a == HistoryAggregateType.TimeAverage),
};
nm.HistorianDataSource = fake;
nm.EnsureVariable("eq/pulse", null, "Pulse", "Boolean", false, historianTagname: "SysPulse", realm: AddressSpaceRealm.Uns);
nm.EnsureVariable("eq/sec", null, "Sec", "Int32", false, historianTagname: "SysTimeSec", realm: AddressSpaceRealm.Uns);
var pulse = nm.TryGetVariable("eq/pulse")!.NodeId;
var (_, errors) = InvokeHistoryRead(server, nm, ProcessedDetails(ObjectIds.AggregateFunction_TimeAverage), pulse);
errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadAggregateNotSupported.Code);
fake.LastCall.ShouldBeNull();
fake.SupportsForCalls.ShouldContain((HistoryAggregateType.TimeAverage, (TypeCode?)TypeCode.Boolean),
"the node's scalar type is what the backend is asked about");
InvokeHistoryRead(server, nm, ProcessedDetails(ObjectIds.AggregateFunction_Average), pulse)
.Item2[0].StatusCode.Code.ShouldBe(StatusCodes.Good.Code);
fake.LastAggregate.ShouldBe(HistoryAggregateType.Average);
InvokeHistoryRead(server, nm, ProcessedDetails(ObjectIds.AggregateFunction_TimeAverage), nm.TryGetVariable("eq/sec")!.NodeId)
.Item2[0].StatusCode.Code.ShouldBe(StatusCodes.Good.Code);
fake.LastTagname.ShouldBe("SysTimeSec");
fake.LastAggregate.ShouldBe(HistoryAggregateType.TimeAverage);
}
finally { await host.DisposeAsync(); }
}
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 Func<CancellationToken, Task<HistorianRead>>? RawRead { get; init; }
public bool ThrowOnRead { get; init; }
public Exception? ReadException { 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 Func<HistoryAggregateType, TypeCode?, bool>? SupportsFor { get; init; }
public List<(HistoryAggregateType Aggregate, TypeCode? Type)> SupportsForCalls { get; } = [];
public bool SupportsAggregateFor(HistoryAggregateType aggregate, TypeCode? valueType)
{
lock (SupportsForCalls) SupportsForCalls.Add((aggregate, valueType));
return SupportsFor?.Invoke(aggregate, valueType) ?? true;
}
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 (ReadException is not null) throw ReadException;
if (ThrowOnRead) throw new InvalidOperationException("backend boom");
LastCall = "Raw";
LastTagname = fullReference;
LastStart = startUtc;
LastEnd = endUtc;
LastMaxValues = maxValuesPerNode;
return RawRead?.Invoke(cancellationToken) ?? Task.FromResult(RawResult);
}
public Task<HistorianRead> ReadProcessedAsync(
string fullReference, DateTime startUtc, DateTime endUtc, TimeSpan interval,
HistoryAggregateType aggregate, CancellationToken cancellationToken)
{
if (ReadException is not null) throw ReadException;
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 (ReadException is not null) throw ReadException;
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 */ }
}
}
}