fix(opcuaclient): #649 review minors - CodeBits match, Bad-only exception ctor, BadNoData + Raw-resume tests, latent-impact docs
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
+19
@@ -17,6 +17,7 @@ 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 BadNoData = 0x809B0000u;
|
||||
private const uint BadUserAccessDenied = 0x801F0000u;
|
||||
private const uint BadHistoryOperationUnsupported = 0x80720000u;
|
||||
|
||||
@@ -55,6 +56,9 @@ public sealed class OpcUaClientDriverHistoryWireTests
|
||||
// Minimum stands in for "the backend refuses with some OTHER Bad status" (#649).
|
||||
if (aggregate == HistoryAggregateType.Minimum)
|
||||
throw new HistoryReadStatusException(BadUserAccessDenied);
|
||||
// Maximum stands in for a node-level BadNoData ("nothing in the window"), which must stay empty.
|
||||
if (aggregate == HistoryAggregateType.Maximum)
|
||||
throw new HistoryReadStatusException(BadNoData);
|
||||
if (aggregate != HistoryAggregateType.Average)
|
||||
throw new HistoryAggregateNotSupportedException(aggregate);
|
||||
|
||||
@@ -204,6 +208,21 @@ public sealed class OpcUaClientDriverHistoryWireTests
|
||||
lock (fake.Calls) fake.Calls.ShouldContain($"processed:{HistTag}:Minimum");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_node_level_BadNoData_stays_an_empty_result_not_an_error()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var (rig, fake, drv) = await StartAsync("hist-badnodata", ct);
|
||||
await using var _r = rig;
|
||||
await using var _d = drv;
|
||||
|
||||
var result = await drv.ReadProcessedAsync(
|
||||
HistRef, At(0), At(6), TimeSpan.FromSeconds(2), HistoryAggregateType.Maximum, ct);
|
||||
|
||||
result.Samples.ShouldBeEmpty();
|
||||
lock (fake.Calls) fake.Calls.ShouldContain($"processed:{HistTag}:Maximum", "the call must have reached the backend");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_Good_read_with_no_samples_in_the_window_is_still_empty_data_not_an_error()
|
||||
{
|
||||
|
||||
@@ -312,6 +312,61 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>A passthrough backend's Bad status on a RESUME read surfaces verbatim for that node only (#649):
|
||||
/// no outbound continuation point, the cursor is consumed (no stray cursor), and a sibling node in the same
|
||||
/// batch is unaffected.</summary>
|
||||
[Fact]
|
||||
public async Task Raw_resume_read_that_throws_HistoryReadStatusException_surfaces_the_code_and_isolates_the_sibling()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
var nm = server.NodeManager!;
|
||||
nm.HistoryContinuationStore = new InMemoryHistoryContinuationStore();
|
||||
var fake = new SeriesHistorianDataSource(MakeSeries(count: 250, stepSeconds: 1));
|
||||
nm.HistorianDataSource = fake;
|
||||
|
||||
nm.EnsureVariable("eq-1/bad", parentFolderNodeId: null, displayName: "Bad", dataType: "Double",
|
||||
writable: false, historianTagname: "WW.Bad", realm: AddressSpaceRealm.Uns);
|
||||
nm.EnsureVariable("eq-1/ok", parentFolderNodeId: null, displayName: "Ok", dataType: "Double",
|
||||
writable: false, historianTagname: "WW.Ok", realm: AddressSpaceRealm.Uns);
|
||||
var badId = nm.TryGetVariable("eq-1/bad")!.NodeId;
|
||||
var okId = nm.TryGetVariable("eq-1/ok")!.NodeId;
|
||||
|
||||
// Page 1 succeeds and emits a continuation point.
|
||||
var (_, _, cp1) = ReadRaw(nm, badId, Epoch, Epoch.AddHours(1), max: 100, inboundCp: null);
|
||||
cp1.ShouldNotBeNull();
|
||||
|
||||
// The backend now refuses the bad node; one batch resumes it and freshly reads the sibling.
|
||||
fake.FailTagname = "WW.Bad";
|
||||
var context = new OperationContext(
|
||||
new RequestHeader(), secureChannelContext: null, RequestType.HistoryRead, identity: null);
|
||||
var details = new ReadRawModifiedDetails
|
||||
{
|
||||
StartTime = Epoch, EndTime = Epoch.AddHours(1), NumValuesPerNode = 100, IsReadModified = false,
|
||||
};
|
||||
var nodesToRead = new List<HistoryReadValueId>
|
||||
{
|
||||
new() { NodeId = badId, ContinuationPoint = cp1 },
|
||||
new() { NodeId = okId },
|
||||
};
|
||||
var results = new List<SdkHistoryReadResult> { null!, null! };
|
||||
var errors = new List<ServiceResult> { null!, null! };
|
||||
nm.HistoryRead(context, details, TimestampsToReturn.Both,
|
||||
releaseContinuationPoints: false, nodesToRead, results, errors);
|
||||
|
||||
errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied);
|
||||
results[0].ContinuationPoint.ShouldBeNull();
|
||||
errors[1].StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||
((HistoryData)ExtensionObject.ToEncodeable(results[1].HistoryData)).DataValues.Count.ShouldBe(100);
|
||||
results[1].ContinuationPoint.ShouldNotBeNull("the sibling is full-page and pages normally");
|
||||
|
||||
// The consumed cursor is gone: nothing stray to resume.
|
||||
fake.FailTagname = null;
|
||||
var (_, e, _) = ReadRaw(nm, badId, Epoch, Epoch.AddHours(1), max: 100, inboundCp: cp1);
|
||||
e.StatusCode.Code.ShouldBe(StatusCodes.BadContinuationPointInvalid);
|
||||
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
// --- helpers --------------------------------------------------------------------------------
|
||||
|
||||
/// <summary>Issue a single-node Raw HistoryRead and return that node's decoded sample values, error,
|
||||
@@ -412,6 +467,9 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable
|
||||
{
|
||||
private int _readCount;
|
||||
public int ReadCount => _readCount;
|
||||
|
||||
/// <summary>When set, a Raw read of this tagname throws <see cref="HistoryReadStatusException"/> (#649).</summary>
|
||||
public volatile string? FailTagname;
|
||||
public void ResetReadCount() => _readCount = 0;
|
||||
|
||||
public Task<HistorianRead> ReadRawAsync(
|
||||
@@ -419,6 +477,8 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Interlocked.Increment(ref _readCount);
|
||||
if (FailTagname is { } bad && bad == fullReference)
|
||||
throw new HistoryReadStatusException(StatusCodes.BadUserAccessDenied);
|
||||
var window = series
|
||||
.Where(s => (s.SourceTimestampUtc ?? DateTime.MinValue) >= startUtc
|
||||
&& (s.SourceTimestampUtc ?? DateTime.MinValue) <= endUtc)
|
||||
|
||||
Reference in New Issue
Block a user