bea0b482d4
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.
215 lines
7.6 KiB
C#
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);
|
|
}
|