fix(historian): respect gateway ceilings and history value contracts
This commit is contained in:
+9
-5
@@ -295,12 +295,14 @@ public sealed class NodeManagerHistoryReadEventsTests : IDisposable
|
||||
/// HistoryRead call. The fake source MUST be invoked (proving we reached the bridge) and threw;
|
||||
/// the production catch now sets BOTH errors and results explicitly, so both are asserted here
|
||||
/// rather than relying on the SDK base pre-seeding results[i].</summary>
|
||||
[Fact]
|
||||
public async Task Events_backend_throw_yields_bad_status_and_does_not_escape()
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public async Task Events_backend_throw_yields_bad_status_and_does_not_escape(bool communication)
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
var nm = server.NodeManager!;
|
||||
var fake = new RecordingHistorianDataSource { ThrowOnRead = true };
|
||||
var fake = new RecordingHistorianDataSource { ThrowOnRead = !communication, ReadException = communication ? new System.IO.IOException("lost connection") : null };
|
||||
nm.HistorianDataSource = fake;
|
||||
|
||||
const string equipmentId = "eq-boom";
|
||||
@@ -321,10 +323,10 @@ public sealed class NodeManagerHistoryReadEventsTests : IDisposable
|
||||
|
||||
// Authoritative per-node signal: errors[0] must be Bad.
|
||||
ServiceResult.IsBad(errors[0]).ShouldBeTrue();
|
||||
errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported);
|
||||
errors[0].StatusCode.Code.ShouldBe(communication ? StatusCodes.BadNoCommunication : StatusCodes.BadHistoryOperationUnsupported);
|
||||
// The production catch now sets results[0] explicitly — assert it directly (not relying on base seeding).
|
||||
results[0].ShouldNotBeNull();
|
||||
results[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported);
|
||||
results[0].StatusCode.Code.ShouldBe(communication ? StatusCodes.BadNoCommunication : StatusCodes.BadHistoryOperationUnsupported);
|
||||
// The source WAS entered (proving the override reached the bridge and the throw was swallowed).
|
||||
fake.EventsEntered.ShouldBeTrue();
|
||||
// ThrowOnRead fires before EventsCalled/LastSourceName are set — that's the throw path.
|
||||
@@ -380,6 +382,7 @@ public sealed class NodeManagerHistoryReadEventsTests : IDisposable
|
||||
private sealed class RecordingHistorianDataSource : IHistorianDataSource
|
||||
{
|
||||
public bool ThrowOnRead { get; init; }
|
||||
public Exception? ReadException { get; init; }
|
||||
public HistoricalEventsResult EventsResult { get; set; } =
|
||||
new(Array.Empty<HistoricalEvent>(), null);
|
||||
|
||||
@@ -411,6 +414,7 @@ public sealed class NodeManagerHistoryReadEventsTests : IDisposable
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
EventsEntered = true;
|
||||
if (ReadException is not null) throw ReadException;
|
||||
if (ThrowOnRead) throw new InvalidOperationException("backend boom for events");
|
||||
EventsCalled = true;
|
||||
LastSourceName = sourceName;
|
||||
|
||||
+30
-2
@@ -25,6 +25,34 @@ public sealed class NodeManagerHistoryReadModifiedTests : IDisposable
|
||||
private static readonly DateTime Epoch = new(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
private readonly string _pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-modified-{Guid.NewGuid():N}");
|
||||
|
||||
[Fact]
|
||||
public async Task Modified_values_use_the_scalar_node_type_and_preserve_the_audit_information()
|
||||
{
|
||||
var (host, nm, node, _) = await SetupAsync([Mod(1, 1, 2d, HistoryModificationType.Replace, "alice", Epoch)], "Float");
|
||||
try
|
||||
{
|
||||
var r = Read(nm, node, Epoch, Epoch.AddHours(1), 10, null);
|
||||
r.Data!.DataValues.Single().Value.ShouldBeOfType<float>().ShouldBe(2f);
|
||||
r.Data.ModificationInfos.Single().UserName.ShouldBe("alice");
|
||||
r.Data.ModificationInfos.Single().UpdateType.ShouldBe(HistoryUpdateType.Replace);
|
||||
}
|
||||
finally { await host.DisposeAsync(); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_modified_read_of_an_unreachable_backend_answers_BadNoCommunication()
|
||||
{
|
||||
var (host, nm, node, src) = await SetupAsync([]);
|
||||
try
|
||||
{
|
||||
src.Throw = new System.IO.IOException("connection lost");
|
||||
var r = Read(nm, node, Epoch, Epoch.AddHours(1), 10, null);
|
||||
r.Error.StatusCode.Code.ShouldBe(StatusCodes.BadNoCommunication);
|
||||
r.Result.StatusCode.Code.ShouldBe(StatusCodes.BadNoCommunication);
|
||||
}
|
||||
finally { await host.DisposeAsync(); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_modified_read_dispatches_to_the_trail_and_projects_modification_infos()
|
||||
{
|
||||
@@ -289,7 +317,7 @@ public sealed class NodeManagerHistoryReadModifiedTests : IDisposable
|
||||
}
|
||||
|
||||
private async Task<(OpcUaApplicationHost Host, OtOpcUaNodeManager Nm, NodeId Node, SqlLikeTrail Src)> SetupAsync(
|
||||
List<HistoryModifiedValue> trail)
|
||||
List<HistoryModifiedValue> trail, string dataType = "Double")
|
||||
{
|
||||
var started = await OpcUaHostStart.StartAsync(
|
||||
port => new OpcUaApplicationHost(
|
||||
@@ -307,7 +335,7 @@ public sealed class NodeManagerHistoryReadModifiedTests : IDisposable
|
||||
nm.HistoryContinuationStore = new InMemoryHistoryContinuationStore();
|
||||
var src = new SqlLikeTrail(trail);
|
||||
nm.HistorianDataSource = src;
|
||||
nm.EnsureVariable("eq-1/mod", null, "Mod", "Double", false, historianTagname: Tag, realm: AddressSpaceRealm.Uns);
|
||||
nm.EnsureVariable("eq-1/mod", null, "Mod", dataType, false, historianTagname: Tag, realm: AddressSpaceRealm.Uns);
|
||||
return (started.Host, nm, nm.TryGetVariable("eq-1/mod")!.NodeId, src);
|
||||
}
|
||||
|
||||
|
||||
@@ -786,6 +786,132 @@ public sealed class NodeManagerHistoryReadTests : IDisposable
|
||||
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<int>().ShouldBe(1);
|
||||
if (dataType == "Boolean") actual.Value.ShouldBeOfType<bool>().ShouldBeTrue();
|
||||
if (dataType == "Float") actual.Value.ShouldBeOfType<float>().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<int[]>().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<double>().ShouldBe(3d);
|
||||
}
|
||||
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
|
||||
@@ -793,6 +919,7 @@ public sealed class NodeManagerHistoryReadTests : IDisposable
|
||||
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<HistoryAggregateType>? Supported { get; init; } = HistoryAggregateTypes.All;
|
||||
@@ -814,6 +941,7 @@ public sealed class NodeManagerHistoryReadTests : IDisposable
|
||||
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;
|
||||
@@ -827,6 +955,7 @@ public sealed class NodeManagerHistoryReadTests : IDisposable
|
||||
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");
|
||||
@@ -842,6 +971,7 @@ public sealed class NodeManagerHistoryReadTests : IDisposable
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user