fix(opcuaclient): HistoryRead honours the per-node StatusCode (BadAggregateNotSupported + other Bad) instead of returning empty data (#649)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
+49
-10
@@ -17,6 +17,8 @@ public sealed class OpcUaClientDriverHistoryWireTests
|
||||
private const string HistRef = "Sim/dev1/Hist";
|
||||
private const string HistTag = "Plant.Hist.Series";
|
||||
private const uint Uncertain = 0x40000000u;
|
||||
private const uint BadUserAccessDenied = 0x801F0000u;
|
||||
private const uint BadHistoryOperationUnsupported = 0x80720000u;
|
||||
|
||||
private static readonly DateTime T0 = new(2026, 1, 1, 12, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
@@ -50,6 +52,9 @@ public sealed class OpcUaClientDriverHistoryWireTests
|
||||
HistoryAggregateType aggregate, CancellationToken ct)
|
||||
{
|
||||
lock (Calls) Calls.Add($"processed:{fullReference}:{aggregate}");
|
||||
// Minimum stands in for "the backend refuses with some OTHER Bad status" (#649).
|
||||
if (aggregate == HistoryAggregateType.Minimum)
|
||||
throw new HistoryReadStatusException(BadUserAccessDenied);
|
||||
if (aggregate != HistoryAggregateType.Average)
|
||||
throw new HistoryAggregateNotSupportedException(aggregate);
|
||||
|
||||
@@ -162,7 +167,7 @@ public sealed class OpcUaClientDriverHistoryWireTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadProcessed_serves_a_supported_aggregate_and_returns_nothing_for_an_unsupported_one()
|
||||
public async Task ReadProcessed_serves_a_supported_aggregate_and_surfaces_BadAggregateNotSupported_for_an_unsupported_one()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var (rig, fake, drv) = await StartAsync("hist-processed", ct);
|
||||
@@ -174,25 +179,59 @@ public sealed class OpcUaClientDriverHistoryWireTests
|
||||
avg.Samples.Select(s => Convert.ToDouble(s.Value)).ShouldBe([1.5, 3.5, 5.0]);
|
||||
avg.Samples.Select(s => s.SourceTimestampUtc).ShouldBe([At(0), At(2), At(4)]);
|
||||
|
||||
// The backend refuses Count: the server maps that to BadAggregateNotSupported for the node, which the
|
||||
// driver surfaces as no samples (it does not expose the per-node status). The Average above is the
|
||||
// positive control that the same path serves data, and the call must have REACHED the backend.
|
||||
var count = await drv.ReadProcessedAsync(HistRef, At(0), At(6), TimeSpan.FromSeconds(2), HistoryAggregateType.Count, ct);
|
||||
count.Samples.ShouldBeEmpty();
|
||||
// The backend refuses Count: the server maps that to BadAggregateNotSupported for the node, and the
|
||||
// driver must raise it (not return empty Good data) so its own server surfaces it per node (#649).
|
||||
// The Average above is the positive control that the same path serves data, and the call must have
|
||||
// REACHED the backend.
|
||||
var ex = await Should.ThrowAsync<HistoryAggregateNotSupportedException>(() =>
|
||||
drv.ReadProcessedAsync(HistRef, At(0), At(6), TimeSpan.FromSeconds(2), HistoryAggregateType.Count, ct));
|
||||
ex.Aggregate.ShouldBe(HistoryAggregateType.Count);
|
||||
lock (fake.Calls) fake.Calls.ShouldContain($"processed:{HistTag}:Count");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_read_of_a_non_historized_node_returns_nothing_and_never_reaches_the_historian()
|
||||
public async Task ReadProcessed_surfaces_any_other_Bad_node_status_instead_of_empty_data()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var (rig, fake, drv) = await StartAsync("hist-badstatus", ct);
|
||||
await using var _r = rig;
|
||||
await using var _d = drv;
|
||||
|
||||
var ex = await Should.ThrowAsync<HistoryReadStatusException>(() =>
|
||||
drv.ReadProcessedAsync(HistRef, At(0), At(6), TimeSpan.FromSeconds(2), HistoryAggregateType.Minimum, ct));
|
||||
|
||||
ex.StatusCode.ShouldBe(BadUserAccessDenied);
|
||||
lock (fake.Calls) fake.Calls.ShouldContain($"processed:{HistTag}:Minimum");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_Good_read_with_no_samples_in_the_window_is_still_empty_data_not_an_error()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var (rig, _, drv) = await StartAsync("hist-goodnodata", ct);
|
||||
await using var _r = rig;
|
||||
await using var _d = drv;
|
||||
|
||||
// Historized node, window past the series: the server answers GoodNoData, which must stay a normal empty result.
|
||||
var result = await drv.ReadRawAsync(HistRef, At(100), At(110), 0, ct);
|
||||
|
||||
result.Samples.ShouldBeEmpty();
|
||||
result.ContinuationPoint.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_read_of_a_non_historized_node_surfaces_its_Bad_status_and_never_reaches_the_historian()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var (rig, fake, drv) = await StartAsync("hist-none", ct);
|
||||
await using var _r = rig;
|
||||
await using var _d = drv;
|
||||
|
||||
var result = await drv.ReadRawAsync(OpcUaClientDriverWireTests.SpeedRef, At(0), At(10), 0, ct);
|
||||
|
||||
result.Samples.ShouldBeEmpty();
|
||||
// The server answers BadHistoryOperationUnsupported for a node with no historian tagname; that Bad
|
||||
// node status is surfaced (#649), no longer read as empty Good data.
|
||||
var ex = await Should.ThrowAsync<HistoryReadStatusException>(() =>
|
||||
drv.ReadRawAsync(OpcUaClientDriverWireTests.SpeedRef, At(0), At(10), 0, ct));
|
||||
ex.StatusCode.ShouldBe(BadHistoryOperationUnsupported);
|
||||
// Positive control: the historized sibling does reach the historian on the same session.
|
||||
(await drv.ReadRawAsync(HistRef, At(0), At(10), 0, ct)).Samples.Count.ShouldBe(5);
|
||||
lock (fake.Calls) fake.Calls.Count.ShouldBe(1, "only the historized read dispatches");
|
||||
|
||||
Reference in New Issue
Block a user