Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/EventHorizonTests.cs
T
Joseph Doherty 3c3513ea3e fix(historian): #694 page raw reads at a strict gateway's learned ceiling; opt-in EventReadHorizon
Item 1: IHistorianDataSource.MaxRawValuesPerRead (Gateway: learned ceiling - 16 = L; 0 elsewhere, guard-tested).
RawPageLimit plans every raw page on top of #692's lookahead: with L > 0 the read (page P + boundary ties k
+ lookahead) is <= L, P = min(N, L - k - 1) (uncapped: L - k - 1); no room => straight to the tie-cluster
over-fetch (never an uncapped or underflowing request). One re-plan + re-read when the read taught a new
limit or was refused for crowding pre-window rows (HistoryReadLimitExceededException.PreWindowRows): the
page shrinks by them. Over-fetch bound min(MaxTieClusterOverfetch, L - 1); slice size never 0.
Review fixes: F1 a window starting at/before the epoch counts AVEVA's synthetic row as pre-window (a
ceiling-truncated read was reported complete); F2 the page path disables ReverseRawReader's own re-run
(cold end-only ceiling 20/N=3000 cost 3,023 reads, now <= 40); F3 crowded look-back re-read around, not
refused; F4 the #687 crowding re-read room at least doubles (up to 8 re-reads), so a cluster of hundreds
just before a resume point no longer fails on a default gateway; a crowding refusal that leaves no room
is BadTooManyOperations, never a tick-by-tick cursor.

Item 2: ServerHistorian:EventReadHorizon (default 00:00:00 = exact, unchanged). When set, every fresh
HistoryReadEvents window's earlier bound is raised to now - horizon (both directions, all backends).
Lossy by design; documented.

Item 3 (native TimeAverage/Total re-check) stays blocked on historiangw#27.
2026-10-13 01:39:32 -04:00

64 lines
2.5 KiB
C#

using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
/// <summary>Gitea #694: <see cref="EventHorizon.TryClamp"/> — the pure window arithmetic of <c>ServerHistorian:EventReadHorizon</c>.</summary>
public sealed class EventHorizonTests
{
private static readonly DateTime Now = new(2026, 10, 13, 12, 0, 0, DateTimeKind.Utc);
private static readonly TimeSpan H = TimeSpan.FromDays(30);
private static readonly DateTime Floor = Now - H;
[Fact]
public void No_horizon_changes_nothing()
{
var (s, e) = (DateTime.MinValue, Now);
EventHorizon.TryClamp(ref s, ref e, reverse: false, TimeSpan.Zero, Now).ShouldBeTrue();
(s, e).ShouldBe((DateTime.MinValue, Now));
EventHorizon.TryClamp(ref s, ref e, reverse: false, TimeSpan.FromDays(-1), Now).ShouldBeTrue();
(s, e).ShouldBe((DateTime.MinValue, Now));
}
[Fact]
public void A_forward_window_starts_at_the_floor()
{
var (s, e) = (DateTime.UnixEpoch, Now);
EventHorizon.TryClamp(ref s, ref e, reverse: false, H, Now).ShouldBeTrue();
(s, e).ShouldBe((Floor, Now));
var (s2, e2) = (Floor.AddDays(1), Now);
EventHorizon.TryClamp(ref s2, ref e2, reverse: false, H, Now).ShouldBeTrue();
s2.ShouldBe(Floor.AddDays(1)); // already inside: unchanged
}
[Fact]
public void A_reverse_window_keeps_the_floor_instant()
{
var (s, e) = (Now, new DateTime(0, DateTimeKind.Utc));
EventHorizon.TryClamp(ref s, ref e, reverse: true, H, Now).ShouldBeTrue();
(s, e).ShouldBe((Now, Floor.AddTicks(-1))); // (end, start] — the floor itself is read
}
[Fact]
public void A_window_wholly_before_the_floor_is_empty()
{
var (s, e) = (Floor.AddDays(-2), Floor);
EventHorizon.TryClamp(ref s, ref e, reverse: false, H, Now).ShouldBeFalse(); // [start, end) ends at the floor
var (rs, re) = (Floor.AddTicks(-1), Floor.AddDays(-2));
EventHorizon.TryClamp(ref rs, ref re, reverse: true, H, Now).ShouldBeFalse();
var (ok, oe) = (Floor, Floor.AddDays(-2));
EventHorizon.TryClamp(ref ok, ref oe, reverse: true, H, Now).ShouldBeTrue(); // (…, floor] holds the floor
}
[Fact]
public void A_horizon_longer_than_the_calendar_is_no_horizon()
{
var (s, e) = (DateTime.MinValue, Now);
EventHorizon.TryClamp(ref s, ref e, reverse: false, TimeSpan.MaxValue, Now).ShouldBeTrue();
s.ShouldBe(DateTime.MinValue);
}
}