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; /// /// Phase C Task 3 — the node-manager's OPC UA HistoryRead override (Raw / Processed / AtTime) over /// historized variable nodes. Boots a real (the same harness /// uses), materialises a historized variable via /// , wires a recording fake /// , then invokes the node manager's PUBLIC /// HistoryRead(OperationContext, …) 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. /// 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}"); /// 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. [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(); } /// 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. [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(); } /// Empty samples ⇒ the node's StatusCode is GoodNoData (the node is historized, the window /// just held no data). [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(); } /// A non-historized node (plain variable, no HistoryRead bit) reaching HistoryRead yields /// BadHistoryOperationUnsupported — the source is never invoked. [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(); } /// /// #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. /// [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(); } /// Processed read with a known aggregate (Average) reaches the source as /// HistoryAggregateType.Average + the ProcessingInterval as a TimeSpan. [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(); } /// Processed read with an UNKNOWN aggregate NodeId ⇒ BadAggregateNotSupported; the source /// is never invoked. [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(); } /// /// #682: an aggregate the server maps but the wired backend does not declare in /// is refused BadAggregateNotSupported 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. /// [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.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(); } /// /// #682 review: an out-of-tree source returning a NULL (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. /// [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(); } /// /// #677: a client AggregateConfiguration that differs from the server defaults is refused per node with /// BadAggregateConfigurationRejected (it used to be silently ignored); the source is never invoked. /// [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(); } /// /// #677: UseServerCapabilitiesDefaults (whatever the other fields say), or a configuration spelling out exactly /// the server defaults, is served. /// [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(); } /// #677: the server's declared aggregate defaults are the SDK's own server defaults. [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(); } /// #285: a Part 13 aggregate beyond the original five (Interpolative) is forwarded to the /// backend as its own member. [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(); } /// #671: the Part 13 TimeAverage2 / Total2 aggregate NodeIds map to their own members. [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(); } /// #285: a backend that cannot serve a mapped aggregate answers BadAggregateNotSupported /// (not the generic BadHistoryOperationUnsupported a plain throw produces). [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(); } /// #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). [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(); } /// AtTime read: the requested timestamps reach ReadAtTimeAsync in order. [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(); } /// /// #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 proves the interface default forwards.) /// [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 DateTimeCollection { 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(); } /// A source recording the AtTime value-type hint per tagname (the hinted overload only). private sealed class HintRecordingSource : IHistorianDataSource { public System.Collections.Concurrent.ConcurrentDictionary Hints { get; } = new(); public Task ReadAtTimeAsync( string fullReference, IReadOnlyList timestampsUtc, TypeCode? valueType, CancellationToken cancellationToken) { Hints[fullReference] = valueType; return Task.FromResult(Empty()); } public Task ReadAtTimeAsync( string fullReference, IReadOnlyList timestampsUtc, CancellationToken cancellationToken) => throw new InvalidOperationException("the node manager must call the hinted overload"); public Task ReadRawAsync(string fullReference, DateTime startUtc, DateTime endUtc, uint maxValuesPerNode, CancellationToken cancellationToken) => Task.FromResult(Empty()); public Task ReadProcessedAsync(string fullReference, DateTime startUtc, DateTime endUtc, TimeSpan interval, HistoryAggregateType aggregate, CancellationToken cancellationToken) => Task.FromResult(Empty()); public Task ReadEventsAsync(string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents, CancellationToken cancellationToken) => Task.FromResult(new HistoricalEventsResult(Array.Empty(), null)); public HistorianHealthSnapshot GetHealthSnapshot() => NullHistorianDataSource.Instance.GetHealthSnapshot(); public void Dispose() { } } /// /// 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. /// [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(); } /// A backend that throws ⇒ that node's error is Bad (not GoodNoData) and no exception /// escapes the HistoryRead call. [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(); } /// Invoke the node manager's public HistoryRead with a single node, returning the filled /// results + errors. Uses a session-less (the /// (RequestHeader, SecureChannelContext, RequestType, IUserIdentity) ctor) — HistoryRead's /// handle-building only needs the NodeId + namespace, not a session. private static (IList Results, IList Errors) InvokeHistoryRead( OtOpcUaSdkServer server, OtOpcUaNodeManager nm, HistoryReadDetails details, NodeId nodeId) => InvokeHistoryRead(server, nm, details, new[] { nodeId }); /// Multi-node overload — issues one HistoryRead batch for all supplied node ids in order. /// Results and errors are returned in the same order as . private static (IList Results, IList 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(null!, nodeIds.Length).ToList(); var errors = Enumerable.Repeat(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 DateTimeCollection { t } }, _ => new ReadProcessedDetails { StartTime = t, EndTime = t.AddSeconds(10), ProcessingInterval = 1000, AggregateType = new NodeIdCollection { ObjectIds.AggregateFunction_Average } } }; var (results, errors) = InvokeHistoryRead(server, nm, details, nm.TryGetVariable("eq/comm")!.NodeId); errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadNoCommunication); results[0].StatusCode.Code.ShouldBe(StatusCodes.BadNoCommunication); } 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, 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 DateTimeCollection { t } }; var (results, errors) = InvokeHistoryRead(server, nm, details, nm.TryGetVariable("eq/typed")!.NodeId); errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good); var actual = ((HistoryData)ExtensionObject.ToEncodeable(results[0].HistoryData)).DataValues.Single(); if (dataType == "Int32") actual.Value.ShouldBeOfType().ShouldBe(1); if (dataType == "Boolean") actual.Value.ShouldBeOfType().ShouldBeTrue(); if (dataType == "Float") actual.Value.ShouldBeOfType().ShouldBe(1f); actual.SourceTimestamp.ShouldBe(t); actual.StatusCode.Code.ShouldBe(StatusCodes.Good); } 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, 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.Single(); value.Value.ShouldBeNull(); value.StatusCode.Code.ShouldBe(StatusCodes.BadTypeMismatch); 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, t, serverTime), new(null, StatusCodes.BadNoCommunication, 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; values[0].Value.ShouldBeOfType().ShouldBe([1, 2]); values[0].StatusCode.Code.ShouldBe(StatusCodes.Uncertain); values[0].SourceTimestamp.ShouldBe(t); values[0].ServerTimestamp.ShouldBe(serverTime); values[1].Value.ShouldBeNull(); values[1].StatusCode.Code.ShouldBe(StatusCodes.BadNoCommunication); } 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, t, t)], null) }; var (results, _) = InvokeHistoryRead(server, nm, new ReadProcessedDetails { StartTime = t, EndTime = t.AddSeconds(10), ProcessingInterval = 1000, AggregateType = new NodeIdCollection { ObjectIds.AggregateFunction_Count } }, nm.TryGetVariable("eq/count")!.NodeId); var value = ((HistoryData)ExtensionObject.ToEncodeable(results[0].HistoryData)).DataValues.Single(); value.Value.ShouldBeOfType().ShouldBe(3d); } finally { await host.DisposeAsync(); } } private static HistorianRead Empty() => new(Array.Empty(), null); /// A recording fake historian source — captures the last call's kind + arguments and returns /// a configured result (or throws when is set). private sealed class RecordingHistorianDataSource : IHistorianDataSource { public bool ThrowOnRead { get; init; } public Exception? ReadException { get; init; } public bool AggregateNotSupported { get; init; } public bool LimitExceeded { get; init; } public IReadOnlySet? Supported { get; init; } = HistoryAggregateTypes.All; public IReadOnlySet SupportedAggregates => Supported!; public HistorianRead RawResult { get; set; } = new(Array.Empty(), null); public HistorianRead ProcessedResult { get; set; } = new(Array.Empty(), null); public HistorianRead AtTimeResult { get; set; } = new(Array.Empty(), 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? LastTimestamps { get; private set; } public Task 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 Task.FromResult(RawResult); } public Task 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 ReadAtTimeAsync( string fullReference, IReadOnlyList 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 ReadEventsAsync( string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents, CancellationToken cancellationToken) => Task.FromResult(new HistoricalEventsResult(Array.Empty(), null)); public HistorianHealthSnapshot GetHealthSnapshot() => NullHistorianDataSource.Instance.GetHealthSnapshot(); public void Dispose() { } } /// A per-tagname historian source that delegates each raw read to a caller-supplied /// . 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). private sealed class PerTagnameHistorianDataSource(Func> rawHandler) : IHistorianDataSource { public Task ReadRawAsync( string fullReference, DateTime startUtc, DateTime endUtc, uint maxValuesPerNode, CancellationToken cancellationToken) => rawHandler(fullReference); public Task ReadProcessedAsync( string fullReference, DateTime startUtc, DateTime endUtc, TimeSpan interval, HistoryAggregateType aggregate, CancellationToken cancellationToken) => NullHistorianDataSource.Instance.ReadProcessedAsync(fullReference, startUtc, endUtc, interval, aggregate, cancellationToken); public Task ReadAtTimeAsync( string fullReference, IReadOnlyList timestampsUtc, CancellationToken cancellationToken) => NullHistorianDataSource.Instance.ReadAtTimeAsync(fullReference, timestampsUtc, cancellationToken); public Task 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.Instance), () => new OtOpcUaSdkServer(), Ct); var host = started.Host; var server = started.Server; return (host, server); } /// Cleans up the PKI root directory. public void Dispose() { if (Directory.Exists(_pkiRoot)) { try { Directory.Delete(_pkiRoot, recursive: true); } catch { /* best-effort cleanup */ } } } }