Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/HistoryPagingTests.cs
T
Joseph Doherty bea0b482d4 fix(historian): address code review on Raw HistoryRead paging
C1 (critical): a boundary tie cluster larger than NumValuesPerNode could
silently truncate a resumed read to GoodNoData, permanently dropping the
un-emitted ties — the (timestamp, skip) cursor cannot advance past a single
timestamp the fixed-(start,end,cap) backend keeps re-returning. Now detected
and failed LOUDLY per node with BadHistoryOperationUnsupported + a log naming
the tag/timestamp/cap; documented in Historian.md with the larger-cap remedy.
Regression test Raw_tie_cluster_larger_than_page_fails_loudly_not_silently.

I3: build HistoryData before Save() so a projection failure can never orphan a
stored continuation cursor.

N1 (YAGNI): drop the never-produced HistoryReadKind enum + Processed-only
Aggregate/IntervalTicks fields from HistoryContinuationState — only Raw pages.

N3: ComputeResumeCursor guards its documented non-empty precondition.

I1: document InMemoryHistoryContinuationStore's eventual-consistency (test double).

Build clean, 182/182 OpcUaServer tests pass.
2026-06-15 05:15:07 -04:00

215 lines
7.6 KiB
C#

using Opc.Ua;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
/// <summary>
/// Pure unit tests for the server-side HistoryRead paging decisions
/// (<see cref="HistoryPaging"/>) and the continuation-point store
/// (<see cref="InMemoryHistoryContinuationStore"/> / round-trip + cap). No server, no session,
/// no SDK — these drive the decision surface directly so the boundary / tie / cap logic is pinned
/// independent of the (operator-driven) live wire-level round trip.
/// </summary>
public sealed class HistoryPagingTests
{
private static DataValueSnapshot Sample(double v, DateTime src) =>
new(v, StatusCodes.Good, src, src);
// --- IsFullPage -----------------------------------------------------------------------------
[Fact]
public void IsFullPage_full_count_with_finite_cap_emits()
{
// Backend returned exactly the cap ⇒ may be more ⇒ page.
HistoryPaging.IsFullPage(returnedCount: 100, numValuesPerNode: 100).ShouldBeTrue();
}
[Fact]
public void IsFullPage_short_page_does_not_emit()
{
// Fewer than the cap ⇒ last page ⇒ no continuation point.
HistoryPaging.IsFullPage(returnedCount: 37, numValuesPerNode: 100).ShouldBeFalse();
}
[Fact]
public void IsFullPage_unlimited_request_never_emits()
{
// NumValuesPerNode == 0 means "all values, no limit" (OPC UA Part 11) ⇒ never page.
HistoryPaging.IsFullPage(returnedCount: 5000, numValuesPerNode: 0).ShouldBeFalse();
}
[Fact]
public void IsFullPage_over_cap_still_emits()
{
// A backend that ignores the cap and over-returns is still treated as "maybe more".
HistoryPaging.IsFullPage(returnedCount: 101, numValuesPerNode: 100).ShouldBeTrue();
}
// --- ComputeResumeCursor --------------------------------------------------------------------
[Fact]
public void ComputeResumeCursor_distinct_timestamps_resumes_at_last_with_single_skip()
{
var t0 = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
var page = new[]
{
Sample(1, t0),
Sample(2, t0.AddSeconds(1)),
Sample(3, t0.AddSeconds(2)),
};
HistoryPaging.ComputeResumeCursor(page, out var nextStart, out var skip);
// Resume from the LAST sample's timestamp, inclusive; exactly one tie at the boundary (itself).
nextStart.ShouldBe(t0.AddSeconds(2));
skip.ShouldBe(1);
}
[Fact]
public void ComputeResumeCursor_tied_boundary_counts_all_ties()
{
var t0 = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
var boundary = t0.AddSeconds(5);
var page = new[]
{
Sample(1, t0),
Sample(2, boundary), // tie 1
Sample(3, boundary), // tie 2
Sample(4, boundary), // tie 3 (last)
};
HistoryPaging.ComputeResumeCursor(page, out var nextStart, out var skip);
nextStart.ShouldBe(boundary);
skip.ShouldBe(3); // all three boundary ties were emitted ⇒ next page must drop them.
}
[Fact]
public void ComputeResumeCursor_null_source_timestamp_falls_back_to_min_value()
{
var page = new[] { new DataValueSnapshot(1.0, StatusCodes.Bad, null, DateTime.UtcNow) };
HistoryPaging.ComputeResumeCursor(page, out var nextStart, out var skip);
nextStart.ShouldBe(DateTime.MinValue);
skip.ShouldBe(1);
}
// --- TrimBoundaryDuplicates -----------------------------------------------------------------
[Fact]
public void TrimBoundaryDuplicates_drops_emitted_ties_keeps_the_rest()
{
var b = new DateTime(2026, 1, 1, 0, 0, 5, DateTimeKind.Utc);
// Resumed read started AT the boundary; the first 2 ties were already emitted last page.
var resumed = new[]
{
Sample(10, b), // already-emitted tie (drop)
Sample(11, b), // already-emitted tie (drop)
Sample(12, b), // NEW un-emitted tie at the boundary (keep)
Sample(13, b.AddSeconds(1)), // past the boundary (keep)
};
var trimmed = HistoryPaging.TrimBoundaryDuplicates(resumed, b, boundarySkipCount: 2);
trimmed.Count.ShouldBe(2);
trimmed[0].Value.ShouldBe(12.0); // the previously-skipped tie is NOT lost.
trimmed[1].Value.ShouldBe(13.0);
}
[Fact]
public void TrimBoundaryDuplicates_zero_skip_is_identity()
{
var b = DateTime.UtcNow;
var resumed = new[] { Sample(1, b), Sample(2, b.AddSeconds(1)) };
HistoryPaging.TrimBoundaryDuplicates(resumed, b, boundarySkipCount: 0)
.ShouldBeSameAs(resumed);
}
[Fact]
public void TrimBoundaryDuplicates_only_trims_matching_boundary_timestamps()
{
var b = new DateTime(2026, 1, 1, 0, 0, 5, DateTimeKind.Utc);
// Skip count says 3, but only the first sample matches the boundary ⇒ trim stops at the mismatch.
var resumed = new[]
{
Sample(10, b),
Sample(11, b.AddSeconds(1)),
Sample(12, b.AddSeconds(2)),
};
var trimmed = HistoryPaging.TrimBoundaryDuplicates(resumed, b, boundarySkipCount: 3);
trimmed.Count.ShouldBe(2);
trimmed[0].Value.ShouldBe(11.0);
}
// --- InMemoryHistoryContinuationStore (mirrors the production session store contract) --------
[Fact]
public void Store_round_trips_state_through_opaque_bytes()
{
var store = new InMemoryHistoryContinuationStore();
var state = new HistoryContinuationState(
"WW.Tag", new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc),
new DateTime(2026, 1, 2, 0, 0, 0, DateTimeKind.Utc), BoundarySkipCount: 2,
NumValuesPerNode: 100);
var cp = store.Save(session: null, state);
cp.ShouldNotBeNull();
cp!.Length.ShouldBe(16); // a 16-byte Guid, matching the production store's encoding.
var taken = store.TryTake(session: null, cp);
taken.ShouldBe(state);
}
[Fact]
public void Store_take_is_single_use()
{
var store = new InMemoryHistoryContinuationStore();
var cp = store.Save(null, RawState())!;
store.TryTake(null, cp).ShouldNotBeNull();
// Second take of the SAME point misses ⇒ caller surfaces BadContinuationPointInvalid.
store.TryTake(null, cp).ShouldBeNull();
}
[Fact]
public void Store_release_drops_the_point()
{
var store = new InMemoryHistoryContinuationStore();
var cp = store.Save(null, RawState())!;
store.Release(null, cp);
store.TryTake(null, cp).ShouldBeNull();
}
[Fact]
public void Store_malformed_point_is_a_miss()
{
var store = new InMemoryHistoryContinuationStore();
store.TryTake(null, new byte[] { 1, 2, 3 }).ShouldBeNull(); // not 16 bytes
store.TryTake(null, Array.Empty<byte>()).ShouldBeNull();
}
[Fact]
public void Store_evicts_oldest_over_capacity()
{
var store = new InMemoryHistoryContinuationStore(capacity: 2);
var cp1 = store.Save(null, RawState())!;
var cp2 = store.Save(null, RawState())!;
var cp3 = store.Save(null, RawState())!; // pushes cp1 out (oldest-eviction)
store.TryTake(null, cp1).ShouldBeNull(); // evicted
store.TryTake(null, cp2).ShouldNotBeNull(); // retained
store.TryTake(null, cp3).ShouldNotBeNull(); // retained
}
private static HistoryContinuationState RawState() => new(
"WW.Tag", DateTime.UtcNow, DateTime.UtcNow.AddHours(1),
BoundarySkipCount: 1, NumValuesPerNode: 50);
}