test: preserve server SDK 2.0 value security and history contracts

This commit is contained in:
Joseph Doherty
2026-11-10 10:37:52 -05:00
parent ac5e9d84f0
commit 9cddc9306b
45 changed files with 1215 additions and 641 deletions
@@ -46,7 +46,7 @@ public sealed class NodeManagerHistoryReadTests : IDisposable
var src = DateTime.UtcNow.AddSeconds(-5);
var srv = DateTime.UtcNow;
fake.RawResult = new HistorianRead(
new[] { new DataValueSnapshot(42.5f, StatusCodes.Good, src, srv) }, null);
new[] { new DataValueSnapshot(42.5f, StatusCodes.Good.Code, src, srv) }, null);
var start = DateTime.UtcNow.AddHours(-1);
var end = DateTime.UtcNow;
@@ -68,13 +68,13 @@ public sealed class NodeManagerHistoryReadTests : IDisposable
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);
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];
dv.Value.ShouldBe(42.5f);
dv.StatusCode.Code.ShouldBe(StatusCodes.Good);
SdkValueBoundary.ToClr(dv.WrappedValue)!.ShouldBe(42.5f);
dv.StatusCode.Code.ShouldBe(StatusCodes.Good.Code);
dv.SourceTimestamp.ShouldBe(src);
dv.ServerTimestamp.ShouldBe(srv);
@@ -140,10 +140,10 @@ public sealed class NodeManagerHistoryReadTests : IDisposable
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();
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();
}
@@ -174,7 +174,7 @@ public sealed class NodeManagerHistoryReadTests : IDisposable
var (_, errors) = InvokeHistoryRead(server, nm, details, nodeId);
errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported);
errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported.Code);
fake.LastCall.ShouldBeNull(); // source never reached
await host.DisposeAsync();
@@ -208,14 +208,14 @@ public sealed class NodeManagerHistoryReadTests : IDisposable
var (results, errors) = InvokeHistoryRead(server, nm, details, nodeId);
errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported);
results[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported);
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);
.ShouldBe(StatusCodes.BadHistoryOperationUnsupported.Code);
await host.DisposeAsync();
}
@@ -239,7 +239,8 @@ public sealed class NodeManagerHistoryReadTests : IDisposable
StartTime = DateTime.UtcNow.AddHours(-1),
EndTime = DateTime.UtcNow,
ProcessingInterval = 10_000.0, // ms
AggregateType = new NodeIdCollection { ObjectIds.AggregateFunction_Average },
AggregateConfiguration = new AggregateConfiguration { UseServerCapabilitiesDefaults = true },
AggregateType = new List<NodeId> { ObjectIds.AggregateFunction_Average },
};
InvokeHistoryRead(server, nm, details, nodeId);
@@ -272,12 +273,13 @@ public sealed class NodeManagerHistoryReadTests : IDisposable
EndTime = DateTime.UtcNow,
ProcessingInterval = 10_000.0,
// StandardDeviationSample is a real OPC UA aggregate we do not serve.
AggregateType = new NodeIdCollection { ObjectIds.AggregateFunction_StandardDeviationSample },
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);
errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadAggregateNotSupported.Code);
fake.LastCall.ShouldBeNull();
await host.DisposeAsync();
@@ -310,12 +312,13 @@ public sealed class NodeManagerHistoryReadTests : IDisposable
StartTime = DateTime.UtcNow.AddHours(-1),
EndTime = DateTime.UtcNow,
ProcessingInterval = 10_000.0,
AggregateType = new NodeIdCollection { aggregate },
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);
.ShouldBe(StatusCodes.BadAggregateNotSupported.Code);
fake.LastCall.ShouldBeNull();
// Positive control: a member of the set reaches the backend.
@@ -326,7 +329,7 @@ public sealed class NodeManagerHistoryReadTests : IDisposable
// 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);
.ShouldBe(StatusCodes.BadAggregateNotSupported.Code);
await host.DisposeAsync();
}
@@ -354,13 +357,14 @@ public sealed class NodeManagerHistoryReadTests : IDisposable
StartTime = DateTime.UtcNow.AddHours(-1),
EndTime = DateTime.UtcNow,
ProcessingInterval = 10_000.0,
AggregateType = new NodeIdCollection { ObjectIds.AggregateFunction_StandardDeviationPopulation },
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 = (CustomNodeManager2)nm.Server.DiagnosticsNodeManager;
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);
@@ -397,7 +401,7 @@ public sealed class NodeManagerHistoryReadTests : IDisposable
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 },
AggregateType = new List<NodeId> { ObjectIds.AggregateFunction_TimeAverage },
AggregateConfiguration = new AggregateConfiguration
{
UseServerCapabilitiesDefaults = false, TreatUncertainAsBad = treatUncertainAsBad,
@@ -405,7 +409,7 @@ public sealed class NodeManagerHistoryReadTests : IDisposable
},
}, nodeId);
errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadAggregateConfigurationRejected);
errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadAggregateConfigurationRejected.Code);
fake.LastCall.ShouldBeNull();
await host.DisposeAsync();
@@ -432,7 +436,7 @@ public sealed class NodeManagerHistoryReadTests : IDisposable
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 },
AggregateType = new List<NodeId> { ObjectIds.AggregateFunction_TimeAverage },
AggregateConfiguration = new AggregateConfiguration
{
UseServerCapabilitiesDefaults = useServerDefaults, TreatUncertainAsBad = treatUncertainAsBad,
@@ -455,7 +459,7 @@ public sealed class NodeManagerHistoryReadTests : IDisposable
[Fact]
public void The_server_aggregate_defaults_deliberately_diverge_from_the_SDK_on_TreatUncertainAsBad()
{
var sdk = new Opc.Ua.Server.AggregateManager(null!).GetDefaultConfiguration(null);
var sdk = new Opc.Ua.Server.AggregateManager(null!).GetDefaultConfiguration(NodeId.Null);
var ours = ServerAggregateConfiguration.Defaults;
ours.TreatUncertainAsBad.ShouldBeFalse();
ours.PercentDataBad.ShouldBe((byte)100);
@@ -476,7 +480,7 @@ public sealed class NodeManagerHistoryReadTests : IDisposable
public async Task The_booted_SDK_AggregateManager_default_is_our_TreatUncertainAsBad_false()
{
var (host, server) = await BootAsync();
var cfg = server.CurrentInstance.AggregateManager.GetDefaultConfiguration(null);
var cfg = server.CurrentInstance.AggregateManager.GetDefaultConfiguration(NodeId.Null);
cfg.TreatUncertainAsBad.ShouldBeFalse();
cfg.PercentDataBad.ShouldBe(ServerAggregateConfiguration.PercentDataBad);
cfg.PercentDataGood.ShouldBe(ServerAggregateConfiguration.PercentDataGood);
@@ -500,7 +504,8 @@ public sealed class NodeManagerHistoryReadTests : IDisposable
InvokeHistoryRead(server, nm, new ReadProcessedDetails
{
StartTime = DateTime.UtcNow.AddHours(-1), EndTime = DateTime.UtcNow, ProcessingInterval = 10_000.0,
AggregateType = new NodeIdCollection { ObjectIds.AggregateFunction_Interpolative },
AggregateConfiguration = new AggregateConfiguration { UseServerCapabilitiesDefaults = true },
AggregateType = new List<NodeId> { ObjectIds.AggregateFunction_Interpolative },
}, nodeId);
fake.LastCall.ShouldBe("Processed");
@@ -528,7 +533,8 @@ public sealed class NodeManagerHistoryReadTests : IDisposable
InvokeHistoryRead(server, nm, new ReadProcessedDetails
{
StartTime = DateTime.UtcNow.AddHours(-1), EndTime = DateTime.UtcNow, ProcessingInterval = 10_000.0,
AggregateType = new NodeIdCollection { aggregateId },
AggregateConfiguration = new AggregateConfiguration { UseServerCapabilitiesDefaults = true },
AggregateType = new List<NodeId> { aggregateId },
}, nodeId);
fake.LastCall.ShouldBe("Processed");
@@ -551,10 +557,11 @@ public sealed class NodeManagerHistoryReadTests : IDisposable
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 },
AggregateConfiguration = new AggregateConfiguration { UseServerCapabilitiesDefaults = true },
AggregateType = new List<NodeId> { ObjectIds.AggregateFunction_PercentGood },
}, nodeId);
errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadAggregateNotSupported);
errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadAggregateNotSupported.Code);
await host.DisposeAsync();
}
@@ -573,10 +580,11 @@ public sealed class NodeManagerHistoryReadTests : IDisposable
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 },
AggregateConfiguration = new AggregateConfiguration { UseServerCapabilitiesDefaults = true },
AggregateType = new List<NodeId> { ObjectIds.AggregateFunction_Total },
}, nodeId);
errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadTooManyOperations);
errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadTooManyOperations.Code);
await host.DisposeAsync();
}
@@ -597,7 +605,7 @@ public sealed class NodeManagerHistoryReadTests : IDisposable
var t2 = DateTime.UtcNow.AddMinutes(-1);
var details = new ReadAtTimeDetails
{
ReqTimes = new DateTimeCollection { t1, t2 },
ReqTimes = new List<DateTimeUtc> { t1, t2 },
};
InvokeHistoryRead(server, nm, details, nodeId);
@@ -638,7 +646,7 @@ public sealed class NodeManagerHistoryReadTests : IDisposable
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);
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);
@@ -707,7 +715,7 @@ public sealed class NodeManagerHistoryReadTests : IDisposable
// 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))
? 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;
@@ -725,20 +733,20 @@ public sealed class NodeManagerHistoryReadTests : IDisposable
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);
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);
data.DataValues[0].Value.ShouldBe(1.0f);
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);
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 == null || ExtensionObject.IsNull(results[1].HistoryData)).ShouldBeTrue();
results[1].HistoryData.IsNull.ShouldBeTrue();
await host.DisposeAsync();
}
@@ -770,13 +778,47 @@ public sealed class NodeManagerHistoryReadTests : IDisposable
var (results, errors) = InvokeHistoryRead(server, nm, details, nodeId);
StatusCode.IsBad(errors[0].StatusCode).ShouldBeTrue();
errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported);
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
@@ -791,7 +833,7 @@ public sealed class NodeManagerHistoryReadTests : IDisposable
OtOpcUaSdkServer server, OtOpcUaNodeManager nm, HistoryReadDetails details, params NodeId[] nodeIds)
{
var context = new OperationContext(
new RequestHeader(), secureChannelContext: null, RequestType.HistoryRead, identity: null);
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();
@@ -825,13 +867,14 @@ public sealed class NodeManagerHistoryReadTests : IDisposable
HistoryReadDetails details = kind switch
{
"Raw" => new ReadRawModifiedDetails { IsReadModified = false, ReturnBounds = false, StartTime = t, EndTime = t.AddSeconds(10) },
"AtTime" => new ReadAtTimeDetails { ReqTimes = new DateTimeCollection { t } },
"AtTime" => new ReadAtTimeDetails { ReqTimes = new List<DateTimeUtc> { t } },
_ => new ReadProcessedDetails { StartTime = t, EndTime = t.AddSeconds(10), ProcessingInterval = 1000,
AggregateType = new NodeIdCollection { ObjectIds.AggregateFunction_Average } }
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);
results[0].StatusCode.Code.ShouldBe(StatusCodes.BadNoCommunication);
errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadNoCommunication.Code);
results[0].StatusCode.Code.ShouldBe(StatusCodes.BadNoCommunication.Code);
}
finally { await host.DisposeAsync(); }
}
@@ -850,20 +893,20 @@ public sealed class NodeManagerHistoryReadTests : IDisposable
{
var nm = server.NodeManager!;
var t = DateTime.UtcNow.AddMinutes(-1);
var value = new HistorianRead([new DataValueSnapshot(1d, StatusCodes.Good, t, t)], null);
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 DateTimeCollection { t } };
: 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);
var actual = ((HistoryData)ExtensionObject.ToEncodeable(results[0].HistoryData)).DataValues.Single();
if (dataType == "Int32") actual.Value.ShouldBeOfType<int>().ShouldBe(1);
if (dataType == "Boolean") actual.Value.ShouldBeOfType<bool>().ShouldBeTrue();
if (dataType == "Float") actual.Value.ShouldBeOfType<float>().ShouldBe(1f);
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);
actual.StatusCode.Code.ShouldBe(StatusCodes.Good.Code);
}
finally { await host.DisposeAsync(); }
}
@@ -879,12 +922,12 @@ public sealed class NodeManagerHistoryReadTests : IDisposable
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) };
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.Single();
value.Value.ShouldBeNull();
value.StatusCode.Code.ShouldBe(StatusCodes.BadTypeMismatch);
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);
}
@@ -902,17 +945,17 @@ public sealed class NodeManagerHistoryReadTests : IDisposable
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) };
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;
values[0].Value.ShouldBeOfType<int[]>().ShouldBe([1, 2]);
values[0].StatusCode.Code.ShouldBe(StatusCodes.Uncertain);
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);
values[1].Value.ShouldBeNull();
values[1].StatusCode.Code.ShouldBe(StatusCodes.BadNoCommunication);
SdkValueBoundary.ToClr(values[1].WrappedValue)!.ShouldBeNull();
values[1].StatusCode.Code.ShouldBe(StatusCodes.BadNoCommunication.Code);
}
finally { await host.DisposeAsync(); }
}
@@ -926,11 +969,12 @@ public sealed class NodeManagerHistoryReadTests : IDisposable
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) };
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, AggregateType = new NodeIdCollection { ObjectIds.AggregateFunction_Count } }, nm.TryGetVariable("eq/count")!.NodeId);
var value = ((HistoryData)ExtensionObject.ToEncodeable(results[0].HistoryData)).DataValues.Single();
value.Value.ShouldBeOfType<double>().ShouldBe(3d);
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(); }
}
@@ -941,6 +985,7 @@ public sealed class NodeManagerHistoryReadTests : IDisposable
/// 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; }
@@ -971,7 +1016,7 @@ public sealed class NodeManagerHistoryReadTests : IDisposable
LastStart = startUtc;
LastEnd = endUtc;
LastMaxValues = maxValuesPerNode;
return Task.FromResult(RawResult);
return RawRead?.Invoke(cancellationToken) ?? Task.FromResult(RawResult);
}
public Task<HistorianRead> ReadProcessedAsync(