57ae69a05e
R08 1.4: raw and modified history reads share HistoryReadPaging. A read stopped by the caller's cap releases the server continuation point. The raw path also passed releaseContinuationPoints = (cp != null), which released page two instead of reading it, so every raw read was silently truncated to its first server page. R08 1.5: DefaultSubscriptionAdapter removes its Subscription from the SDK Session on delete/dispose (and a failed CreateAsync removes it too), so re-targeted alarm subscriptions no longer accumulate in Session.Subscriptions. Refs #747 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
145 lines
5.9 KiB
C#
145 lines
5.9 KiB
C#
using Opc.Ua;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Client.Shared.Helpers;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Client.Shared.Tests.Helpers;
|
|
|
|
/// <summary>
|
|
/// Pins the client-side HistoryRead paging loop shared by the raw and modified reads: continuation pages are read
|
|
/// (never released) while the caller wants more, and a read stopped by the caller's own cap releases the server cursor.
|
|
/// </summary>
|
|
public class HistoryReadPagingTests
|
|
{
|
|
private static readonly NodeId Node = new("t", 2);
|
|
|
|
/// <summary>One recorded call to the page reader.</summary>
|
|
private sealed record Call(byte[]? ContinuationPoint, bool Release);
|
|
|
|
/// <summary>A scripted server: each read returns the next page; a release returns an empty Good result.</summary>
|
|
private sealed class ScriptedServer
|
|
{
|
|
private readonly Queue<(int Count, byte[]? Cp)> _pages;
|
|
public List<Call> Calls { get; } = [];
|
|
|
|
public ScriptedServer(params (int Count, byte[]? Cp)[] pages) => _pages = new Queue<(int, byte[]?)>(pages);
|
|
|
|
public Task<HistoryReadResult?> ReadPageAsync(byte[]? cp, bool release, CancellationToken ct)
|
|
{
|
|
Calls.Add(new Call(cp, release));
|
|
if (release) return Task.FromResult<HistoryReadResult?>(new HistoryReadResult { StatusCode = StatusCodes.Good });
|
|
var (count, next) = _pages.Dequeue();
|
|
var data = new HistoryData();
|
|
for (var i = 0; i < count; i++) data.DataValues.Add(new DataValue(new Variant(i)));
|
|
return Task.FromResult<HistoryReadResult?>(new HistoryReadResult
|
|
{
|
|
StatusCode = StatusCodes.Good,
|
|
ContinuationPoint = next,
|
|
HistoryData = new ExtensionObject(data)
|
|
});
|
|
}
|
|
}
|
|
|
|
private static IEnumerable<DataValue> Values(HistoryReadResult r) =>
|
|
r.HistoryData?.Body is HistoryData d ? d.DataValues : [];
|
|
|
|
/// <summary>
|
|
/// A server page limit below the caller's cap hands back a continuation point: the follow-up must READ the next
|
|
/// page. Releasing it instead (the old raw path passed <c>release = cp != null</c>) silently truncated every raw read
|
|
/// to its first server page.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Continuation_pages_are_read_not_released_until_the_server_finishes()
|
|
{
|
|
var server = new ScriptedServer((3, [1]), (3, [2]), (2, null));
|
|
|
|
var values = await HistoryReadPaging.ReadAllAsync(server.ReadPageAsync, Values, maxValues: 100, Node,
|
|
CancellationToken.None);
|
|
|
|
values.Count.ShouldBe(8);
|
|
server.Calls.Select(c => c.Release).ShouldAllBe(r => r == false);
|
|
server.Calls.Select(c => c.ContinuationPoint?.FirstOrDefault()).ShouldBe(new byte?[] { null, 1, 2 });
|
|
}
|
|
|
|
/// <summary>A read stopped by the caller's cap while the server still holds a cursor releases that cursor.</summary>
|
|
[Fact]
|
|
public async Task A_read_stopped_by_the_cap_releases_the_outstanding_continuation_point()
|
|
{
|
|
var server = new ScriptedServer((5, [7]));
|
|
|
|
var values = await HistoryReadPaging.ReadAllAsync(server.ReadPageAsync, Values, maxValues: 5, Node,
|
|
CancellationToken.None);
|
|
|
|
values.Count.ShouldBe(5);
|
|
server.Calls.Count.ShouldBe(2);
|
|
server.Calls[1].Release.ShouldBeTrue();
|
|
server.Calls[1].ContinuationPoint.ShouldBe(new byte[] { 7 });
|
|
}
|
|
|
|
/// <summary>
|
|
/// A read the server finished (no continuation point) has nothing to release — and the positive control above
|
|
/// proves a release would have been recorded had one been owed.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task A_read_the_server_finished_issues_no_release()
|
|
{
|
|
var server = new ScriptedServer((5, null));
|
|
|
|
var values = await HistoryReadPaging.ReadAllAsync(server.ReadPageAsync, Values, maxValues: 5, Node,
|
|
CancellationToken.None);
|
|
|
|
values.Count.ShouldBe(5);
|
|
server.Calls.ShouldHaveSingleItem().Release.ShouldBeFalse();
|
|
}
|
|
|
|
/// <summary>An uncapped read (<c>maxValues <= 0</c>) follows every continuation point to the end.</summary>
|
|
[Fact]
|
|
public async Task An_uncapped_read_follows_every_page()
|
|
{
|
|
var server = new ScriptedServer((2, [1]), (2, [2]), (1, null));
|
|
|
|
var values = await HistoryReadPaging.ReadAllAsync(server.ReadPageAsync, Values, maxValues: 0, Node,
|
|
CancellationToken.None);
|
|
|
|
values.Count.ShouldBe(5);
|
|
server.Calls.ShouldAllBe(c => !c.Release);
|
|
}
|
|
|
|
/// <summary>A failed release is best effort: the values already read are still returned.</summary>
|
|
[Fact]
|
|
public async Task A_failed_release_does_not_lose_the_values_read()
|
|
{
|
|
var calls = 0;
|
|
Task<HistoryReadResult?> Read(byte[]? cp, bool release, CancellationToken ct)
|
|
{
|
|
calls++;
|
|
if (release) throw new ServiceResultException(StatusCodes.BadSessionClosed);
|
|
var data = new HistoryData();
|
|
data.DataValues.Add(new DataValue(new Variant(1)));
|
|
return Task.FromResult<HistoryReadResult?>(new HistoryReadResult
|
|
{
|
|
StatusCode = StatusCodes.Good, ContinuationPoint = [9], HistoryData = new ExtensionObject(data)
|
|
});
|
|
}
|
|
|
|
var values = await HistoryReadPaging.ReadAllAsync(Read, Values, maxValues: 1, Node, CancellationToken.None);
|
|
|
|
values.Count.ShouldBe(1);
|
|
calls.ShouldBe(2);
|
|
}
|
|
|
|
/// <summary>A refused page still surfaces as an exception, not an empty read.</summary>
|
|
[Fact]
|
|
public async Task A_refused_page_throws()
|
|
{
|
|
Task<HistoryReadResult?> Read(byte[]? cp, bool release, CancellationToken ct) =>
|
|
Task.FromResult<HistoryReadResult?>(new HistoryReadResult
|
|
{
|
|
StatusCode = StatusCodes.BadHistoryOperationUnsupported
|
|
});
|
|
|
|
await Should.ThrowAsync<ServiceResultException>(() =>
|
|
HistoryReadPaging.ReadAllAsync(Read, Values, maxValues: 10, Node, CancellationToken.None));
|
|
}
|
|
}
|