using Opc.Ua; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests; /// /// Pure unit tests for the server-side HistoryRead paging decisions /// () and the continuation-point store /// ( / 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. /// 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()).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); }