using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests; /// /// Gitea #694: — the page plan (P = min(want, L − k − 1), request = P + k + 1 ≤ L) and the one /// re-plan after a refusal. The end-to-end behaviour over the real Gateway data source is pinned in /// GatewayCeilingPagingTests. /// public sealed class RawPageLimitTests { [Theory] // want, k, L ⇒ page, request, noRoom [InlineData(5000u, 0, 0, 5000u, 5001u, false)] // no limit: #692 unchanged (page + ties + lookahead) [InlineData(5000u, 3, 0, 5000u, 5004u, false)] [InlineData(0u, 3, 0, 0u, 0u, false)] // no limit, uncapped: one uncapped read [InlineData(5000u, 0, 984, 983u, 984u, false)] // over the limit: the request fills the limit exactly [InlineData(5000u, 10, 984, 973u, 984u, false)] // boundary ties take room from the page [InlineData(10u, 10, 984, 10u, 21u, false)] // under the limit: unchanged [InlineData(0u, 0, 984, 983u, 984u, false)] // uncapped: a limit-sized page [InlineData(0u, 982, 984, 1u, 984u, false)] [InlineData(5000u, 983, 984, 0u, 0u, true)] // the ties alone fill a read: never a 0 (uncapped) request [InlineData(5000u, 5000, 984, 0u, 0u, true)] // never an underflow [InlineData(5000u, 0, 1, 0u, 0u, true)] public void Plan_fits_page_ties_and_lookahead_into_the_limit(uint want, int k, int limit, uint page, uint request, bool noRoom) { var plan = RawPageLimit.Plan(want, k, limit); plan.PageCap.ShouldBe(page); plan.RequestCap.ShouldBe(request); plan.NoRoom.ShouldBe(noRoom); if (limit > 0 && !noRoom) plan.RequestCap.ShouldBeLessThanOrEqualTo((uint)limit); } [Fact] public async Task A_read_that_teaches_the_limit_is_re_planned_once_at_it() { var source = new LimitSource(); var asked = new List(); var (page, plan) = await RawPageLimit.ReadAsync(source, 5000u, 0, p => { asked.Add(p.RequestCap); if (p.RequestCap > 984) { source.Limit = 984; throw new HistoryReadLimitExceededException(1000, "ceiling"); } return Task.FromResult("ok"); }); page.ShouldBe("ok"); plan.PageCap.ShouldBe(983u); asked.ShouldBe([5001u, 984u]); } [Fact] public async Task A_crowded_refusal_shrinks_the_page_by_the_pre_window_rows() { var source = new LimitSource { Limit = 984 }; var asked = new List(); var (_, plan) = await RawPageLimit.ReadAsync(source, 0u, 2, p => { asked.Add(p.RequestCap); if (asked.Count == 1) throw new HistoryReadLimitExceededException(1000, "crowded") { PreWindowRows = 21 }; return Task.FromResult("ok"); }); asked.ShouldBe([984u, 963u]); plan.PageCap.ShouldBe(960u); } [Fact] public async Task A_refusal_at_an_unchanged_limit_without_crowding_is_the_answer() { var source = new LimitSource { Limit = 984 }; var calls = 0; await Should.ThrowAsync(() => RawPageLimit.ReadAsync(source, 5000u, 0, _ => { calls++; throw new HistoryReadLimitExceededException(1000, "tie cluster"); })); calls.ShouldBe(1); } [Fact] public async Task A_second_refusal_is_not_retried_again() { var source = new LimitSource(); var calls = 0; await Should.ThrowAsync(() => RawPageLimit.ReadAsync(source, 0u, 0, _ => { calls++; source.Limit = 984 - calls; // even if the limit keeps moving throw new HistoryReadLimitExceededException(1000, "refused") { PreWindowRows = 5 }; })); calls.ShouldBe(2); } [Fact] public async Task No_room_is_returned_without_a_read() { var source = new LimitSource { Limit = 10 }; var calls = 0; var (page, plan) = await RawPageLimit.ReadAsync(source, 5u, 9, _ => { calls++; return Task.FromResult("x"); }); plan.NoRoom.ShouldBeTrue(); page.ShouldBeNull(); calls.ShouldBe(0); } [Fact] public async Task Other_failures_are_not_retried() { var source = new LimitSource(); var calls = 0; await Should.ThrowAsync(() => RawPageLimit.ReadAsync(source, 5000u, 0, _ => { calls++; source.Limit = 984; throw new InvalidOperationException("backend fault"); })); calls.ShouldBe(1); } /// Only matters here. private sealed class LimitSource : IHistorianDataSource { public int Limit; public int MaxRawValuesPerRead => Limit; public Task ReadRawAsync(string fullReference, DateTime startUtc, DateTime endUtc, uint maxValuesPerNode, CancellationToken cancellationToken) => throw new NotSupportedException(); public Task ReadProcessedAsync(string fullReference, DateTime startUtc, DateTime endUtc, TimeSpan interval, HistoryAggregateType aggregate, CancellationToken cancellationToken) => throw new NotSupportedException(); public Task ReadAtTimeAsync(string fullReference, IReadOnlyList timestampsUtc, CancellationToken cancellationToken) => throw new NotSupportedException(); public Task ReadEventsAsync(string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents, CancellationToken cancellationToken) => throw new NotSupportedException(); public HistorianHealthSnapshot GetHealthSnapshot() => NullHistorianDataSource.Instance.GetHealthSnapshot(); public void Dispose() { } } }