Merge hw3-history (#655 delete dead driver-side history capability + client surfaces Bad HistoryRead)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
-258
@@ -1,258 +0,0 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// HistoryRead leg of the OpcUaClient driver's wire coverage (Gitea #622): the real
|
||||
/// <see cref="OpcUaClientDriver"/> issues Raw / Processed / AtTime reads over loopback to the in-process
|
||||
/// OtOpcUa server, whose node manager dispatches to an injected deterministic
|
||||
/// <see cref="IHistorianDataSource"/> (<see cref="FakeHistorian"/>) behind a historized raw variable.
|
||||
/// </summary>
|
||||
[Trait("Category", "Integration")]
|
||||
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;
|
||||
|
||||
private static readonly DateTime T0 = new(2026, 1, 1, 12, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
private static DateTime At(int seconds) => T0.AddSeconds(seconds);
|
||||
|
||||
/// <summary>Five samples, one per second, value = 1..5; the third is Uncertain.</summary>
|
||||
private static readonly DataValueSnapshot[] Series =
|
||||
[
|
||||
new(1.0, 0u, At(0), At(0)),
|
||||
new(2.0, 0u, At(1), At(1)),
|
||||
new(3.0, Uncertain, At(2), At(2)),
|
||||
new(4.0, 0u, At(3), At(3)),
|
||||
new(5.0, 0u, At(4), At(4)),
|
||||
];
|
||||
|
||||
private sealed class FakeHistorian : IHistorianDataSource
|
||||
{
|
||||
public List<string> Calls { get; } = [];
|
||||
|
||||
public Task<HistoryReadResult> ReadRawAsync(
|
||||
string fullReference, DateTime startUtc, DateTime endUtc, uint maxValuesPerNode, CancellationToken ct)
|
||||
{
|
||||
lock (Calls) Calls.Add($"raw:{fullReference}");
|
||||
var hits = Series.Where(s => s.SourceTimestampUtc >= startUtc && s.SourceTimestampUtc < endUtc);
|
||||
if (maxValuesPerNode > 0) hits = hits.Take((int)maxValuesPerNode);
|
||||
return Task.FromResult(new HistoryReadResult([.. hits], null));
|
||||
}
|
||||
|
||||
public Task<HistoryReadResult> ReadProcessedAsync(
|
||||
string fullReference, DateTime startUtc, DateTime endUtc, TimeSpan interval,
|
||||
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);
|
||||
// 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);
|
||||
|
||||
var buckets = new List<DataValueSnapshot>();
|
||||
for (var b = startUtc; b < endUtc; b += interval)
|
||||
{
|
||||
var inBucket = Series.Where(s => s.SourceTimestampUtc >= b && s.SourceTimestampUtc < b + interval).ToList();
|
||||
if (inBucket.Count == 0) continue;
|
||||
buckets.Add(new DataValueSnapshot(inBucket.Average(s => (double)s.Value!), 0u, b, b));
|
||||
}
|
||||
return Task.FromResult(new HistoryReadResult(buckets, null));
|
||||
}
|
||||
|
||||
public Task<HistoryReadResult> ReadAtTimeAsync(
|
||||
string fullReference, IReadOnlyList<DateTime> timestampsUtc, CancellationToken ct)
|
||||
{
|
||||
lock (Calls) Calls.Add($"attime:{fullReference}");
|
||||
// Step semantics: the last sample at or before the requested instant; BadNoData before the first.
|
||||
var result = timestampsUtc.Select(t =>
|
||||
{
|
||||
var prior = Series.LastOrDefault(s => s.SourceTimestampUtc <= t);
|
||||
return prior is null
|
||||
? new DataValueSnapshot(null, 0x809B0000u /* BadNoData */, t, t)
|
||||
: new DataValueSnapshot(prior.Value, prior.StatusCode, t, t);
|
||||
}).ToList();
|
||||
return Task.FromResult(new HistoryReadResult(result, null));
|
||||
}
|
||||
|
||||
public Task<HistoricalEventsResult> ReadEventsAsync(
|
||||
string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents, CancellationToken ct) =>
|
||||
Task.FromResult(new HistoricalEventsResult([], null));
|
||||
|
||||
public HistorianHealthSnapshot GetHealthSnapshot() =>
|
||||
new(0, 0, 0, 0, null, null, null, false, false, null, null, []);
|
||||
|
||||
public void Dispose() { }
|
||||
}
|
||||
|
||||
private static async Task<(ServerEventRig Rig, FakeHistorian Fake, OpcUaClientDriver Drv)> StartAsync(
|
||||
string name, CancellationToken ct)
|
||||
{
|
||||
var rig = await OpcUaClientDriverWireTests.StartRigAsync(name, ct);
|
||||
var fake = new FakeHistorian();
|
||||
rig.Nm.HistorianDataSource = fake;
|
||||
var id = rig.EnsureRawVariable("Hist", "Double", writable: false, historianTagname: HistTag);
|
||||
OpcUaClientDriverWireTests.Publish(rig, id, 5.0);
|
||||
|
||||
var drv = OpcUaClientDriverWireTests.NewDriver(
|
||||
rig, name,
|
||||
extraTags: [OpcUaClientDriverWireTests.Tag(HistRef, ServerEventRig.RawNsu(id))]);
|
||||
await drv.InitializeAsync("{}", ct);
|
||||
return (rig, fake, drv);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadRaw_returns_the_historians_samples_with_values_timestamps_and_statuses()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var (rig, fake, drv) = await StartAsync("hist-raw", ct);
|
||||
await using var _r = rig;
|
||||
await using var _d = drv;
|
||||
|
||||
var result = await drv.ReadRawAsync(HistRef, At(0), At(10), 0, ct);
|
||||
|
||||
result.Samples.Select(s => Convert.ToDouble(s.Value)).ShouldBe([1.0, 2.0, 3.0, 4.0, 5.0]);
|
||||
result.Samples.Select(s => s.SourceTimestampUtc).ShouldBe(Series.Select(s => s.SourceTimestampUtc));
|
||||
result.Samples.Select(s => s.StatusCode).ShouldBe([0u, 0u, Uncertain, 0u, 0u]);
|
||||
result.ContinuationPoint.ShouldBeNull("the whole window fit in one page");
|
||||
lock (fake.Calls) fake.Calls.ShouldContain($"raw:{HistTag}", "the read must reach the historian under the node's tagname");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadRaw_honours_the_window_and_pages_with_a_continuation_point_when_the_cap_is_hit()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var (rig, _, drv) = await StartAsync("hist-raw-page", ct);
|
||||
await using var _r = rig;
|
||||
await using var _d = drv;
|
||||
|
||||
// Window [1s, 4s) holds samples 2,3,4 (end exclusive).
|
||||
var window = await drv.ReadRawAsync(HistRef, At(1), At(4), 0, ct);
|
||||
window.Samples.Select(s => Convert.ToDouble(s.Value)).ShouldBe([2.0, 3.0, 4.0]);
|
||||
|
||||
// A cap of 3 against 5 available: first three samples and a continuation point.
|
||||
var paged = await drv.ReadRawAsync(HistRef, At(0), At(10), 3, ct);
|
||||
paged.Samples.Select(s => Convert.ToDouble(s.Value)).ShouldBe([1.0, 2.0, 3.0]);
|
||||
paged.ContinuationPoint.ShouldNotBeNull("a full page must carry a continuation point");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadAtTime_returns_one_sample_per_requested_instant_with_the_prior_value()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var (rig, fake, drv) = await StartAsync("hist-attime", ct);
|
||||
await using var _r = rig;
|
||||
await using var _d = drv;
|
||||
|
||||
var result = await drv.ReadAtTimeAsync(HistRef, [At(-1), At(1), At(2), At(3).AddMilliseconds(500)], ct);
|
||||
|
||||
result.Samples.Count.ShouldBe(4);
|
||||
result.Samples[0].StatusCode.ShouldNotBe(0u, "before the first sample there is no value");
|
||||
Convert.ToDouble(result.Samples[1].Value).ShouldBe(2.0);
|
||||
result.Samples[1].StatusCode.ShouldBe(0u);
|
||||
Convert.ToDouble(result.Samples[2].Value).ShouldBe(3.0);
|
||||
result.Samples[2].StatusCode.ShouldBe(Uncertain);
|
||||
Convert.ToDouble(result.Samples[3].Value).ShouldBe(4.0);
|
||||
result.Samples.Skip(1).Select(s => s.SourceTimestampUtc)
|
||||
.ShouldBe([At(1), At(2), At(3).AddMilliseconds(500)]);
|
||||
lock (fake.Calls) fake.Calls.ShouldContain($"attime:{HistTag}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
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);
|
||||
await using var _r = rig;
|
||||
await using var _d = drv;
|
||||
|
||||
// Average over 2s buckets across [0s,6s): {1,2}=1.5, {3,4}=3.5, {5}=5.
|
||||
var avg = await drv.ReadProcessedAsync(HistRef, At(0), At(6), TimeSpan.FromSeconds(2), HistoryAggregateType.Average, ct);
|
||||
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, 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 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_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()
|
||||
{
|
||||
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;
|
||||
|
||||
// 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");
|
||||
}
|
||||
}
|
||||
@@ -312,61 +312,6 @@ 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,
|
||||
@@ -468,8 +413,6 @@ 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(
|
||||
@@ -477,8 +420,6 @@ 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