using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient; namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests; /// /// HistoryRead leg of the OpcUaClient driver's wire coverage (Gitea #622): the real /// issues Raw / Processed / AtTime reads over loopback to the in-process /// OtOpcUa server, whose node manager dispatches to an injected deterministic /// () behind a historized raw variable. /// [Trait("Category", "Integration")] public sealed class OpcUaClientDriverHistoryWireTests { private const string HistRef = "Sim/dev1/Hist"; private const string HistTag = "Plant.Hist.Series"; private const uint Uncertain = 0x40000000u; private const uint BadNoData = 0x809B0000u; private const uint BadUserAccessDenied = 0x801F0000u; private const uint BadHistoryOperationUnsupported = 0x80720000u; private static readonly DateTime T0 = new(2026, 1, 1, 12, 0, 0, DateTimeKind.Utc); private static DateTime At(int seconds) => T0.AddSeconds(seconds); /// Five samples, one per second, value = 1..5; the third is Uncertain. private static readonly DataValueSnapshot[] Series = [ new(1.0, 0u, At(0), At(0)), new(2.0, 0u, At(1), At(1)), new(3.0, Uncertain, At(2), At(2)), new(4.0, 0u, At(3), At(3)), new(5.0, 0u, At(4), At(4)), ]; private sealed class FakeHistorian : IHistorianDataSource { public List Calls { get; } = []; public Task ReadRawAsync( string fullReference, DateTime startUtc, DateTime endUtc, uint maxValuesPerNode, CancellationToken ct) { lock (Calls) Calls.Add($"raw:{fullReference}"); var hits = Series.Where(s => s.SourceTimestampUtc >= startUtc && s.SourceTimestampUtc < endUtc); if (maxValuesPerNode > 0) hits = hits.Take((int)maxValuesPerNode); return Task.FromResult(new HistoryReadResult([.. hits], null)); } public Task ReadProcessedAsync( string fullReference, DateTime startUtc, DateTime endUtc, TimeSpan interval, HistoryAggregateType aggregate, CancellationToken ct) { lock (Calls) Calls.Add($"processed:{fullReference}:{aggregate}"); // Minimum stands in for "the backend refuses with some OTHER Bad status" (#649). if (aggregate == HistoryAggregateType.Minimum) throw new HistoryReadStatusException(BadUserAccessDenied); // Maximum stands in for a node-level BadNoData ("nothing in the window"), which must stay empty. if (aggregate == HistoryAggregateType.Maximum) throw new HistoryReadStatusException(BadNoData); if (aggregate != HistoryAggregateType.Average) throw new HistoryAggregateNotSupportedException(aggregate); var buckets = new List(); for (var b = startUtc; b < endUtc; b += interval) { var inBucket = Series.Where(s => s.SourceTimestampUtc >= b && s.SourceTimestampUtc < b + interval).ToList(); if (inBucket.Count == 0) continue; buckets.Add(new DataValueSnapshot(inBucket.Average(s => (double)s.Value!), 0u, b, b)); } return Task.FromResult(new HistoryReadResult(buckets, null)); } public Task ReadAtTimeAsync( string fullReference, IReadOnlyList timestampsUtc, CancellationToken ct) { lock (Calls) Calls.Add($"attime:{fullReference}"); // Step semantics: the last sample at or before the requested instant; BadNoData before the first. var result = timestampsUtc.Select(t => { var prior = Series.LastOrDefault(s => s.SourceTimestampUtc <= t); return prior is null ? new DataValueSnapshot(null, 0x809B0000u /* BadNoData */, t, t) : new DataValueSnapshot(prior.Value, prior.StatusCode, t, t); }).ToList(); return Task.FromResult(new HistoryReadResult(result, null)); } public Task ReadEventsAsync( string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents, CancellationToken ct) => Task.FromResult(new HistoricalEventsResult([], null)); public HistorianHealthSnapshot GetHealthSnapshot() => new(0, 0, 0, 0, null, null, null, false, false, null, null, []); public void Dispose() { } } private static async Task<(ServerEventRig Rig, FakeHistorian Fake, OpcUaClientDriver Drv)> StartAsync( string name, CancellationToken ct) { var rig = await OpcUaClientDriverWireTests.StartRigAsync(name, ct); var fake = new FakeHistorian(); rig.Nm.HistorianDataSource = fake; var id = rig.EnsureRawVariable("Hist", "Double", writable: false, historianTagname: HistTag); OpcUaClientDriverWireTests.Publish(rig, id, 5.0); var drv = OpcUaClientDriverWireTests.NewDriver( rig, name, extraTags: [OpcUaClientDriverWireTests.Tag(HistRef, ServerEventRig.RawNsu(id))]); await drv.InitializeAsync("{}", ct); return (rig, fake, drv); } [Fact] public async Task ReadRaw_returns_the_historians_samples_with_values_timestamps_and_statuses() { var ct = TestContext.Current.CancellationToken; var (rig, fake, drv) = await StartAsync("hist-raw", ct); await using var _r = rig; await using var _d = drv; var result = await drv.ReadRawAsync(HistRef, At(0), At(10), 0, ct); result.Samples.Select(s => Convert.ToDouble(s.Value)).ShouldBe([1.0, 2.0, 3.0, 4.0, 5.0]); result.Samples.Select(s => s.SourceTimestampUtc).ShouldBe(Series.Select(s => s.SourceTimestampUtc)); result.Samples.Select(s => s.StatusCode).ShouldBe([0u, 0u, Uncertain, 0u, 0u]); result.ContinuationPoint.ShouldBeNull("the whole window fit in one page"); lock (fake.Calls) fake.Calls.ShouldContain($"raw:{HistTag}", "the read must reach the historian under the node's tagname"); } [Fact] public async Task ReadRaw_honours_the_window_and_pages_with_a_continuation_point_when_the_cap_is_hit() { var ct = TestContext.Current.CancellationToken; var (rig, _, drv) = await StartAsync("hist-raw-page", ct); await using var _r = rig; await using var _d = drv; // Window [1s, 4s) holds samples 2,3,4 (end exclusive). var window = await drv.ReadRawAsync(HistRef, At(1), At(4), 0, ct); window.Samples.Select(s => Convert.ToDouble(s.Value)).ShouldBe([2.0, 3.0, 4.0]); // A cap of 3 against 5 available: first three samples and a continuation point. var paged = await drv.ReadRawAsync(HistRef, At(0), At(10), 3, ct); paged.Samples.Select(s => Convert.ToDouble(s.Value)).ShouldBe([1.0, 2.0, 3.0]); paged.ContinuationPoint.ShouldNotBeNull("a full page must carry a continuation point"); } [Fact] public async Task ReadAtTime_returns_one_sample_per_requested_instant_with_the_prior_value() { var ct = TestContext.Current.CancellationToken; var (rig, fake, drv) = await StartAsync("hist-attime", ct); await using var _r = rig; await using var _d = drv; var result = await drv.ReadAtTimeAsync(HistRef, [At(-1), At(1), At(2), At(3).AddMilliseconds(500)], ct); result.Samples.Count.ShouldBe(4); result.Samples[0].StatusCode.ShouldNotBe(0u, "before the first sample there is no value"); Convert.ToDouble(result.Samples[1].Value).ShouldBe(2.0); result.Samples[1].StatusCode.ShouldBe(0u); Convert.ToDouble(result.Samples[2].Value).ShouldBe(3.0); result.Samples[2].StatusCode.ShouldBe(Uncertain); Convert.ToDouble(result.Samples[3].Value).ShouldBe(4.0); result.Samples.Skip(1).Select(s => s.SourceTimestampUtc) .ShouldBe([At(1), At(2), At(3).AddMilliseconds(500)]); lock (fake.Calls) fake.Calls.ShouldContain($"attime:{HistTag}"); } [Fact] public async Task ReadProcessed_serves_a_supported_aggregate_and_surfaces_BadAggregateNotSupported_for_an_unsupported_one() { var ct = TestContext.Current.CancellationToken; var (rig, fake, drv) = await StartAsync("hist-processed", ct); await using var _r = rig; await using var _d = drv; // Average over 2s buckets across [0s,6s): {1,2}=1.5, {3,4}=3.5, {5}=5. var avg = await drv.ReadProcessedAsync(HistRef, At(0), At(6), TimeSpan.FromSeconds(2), HistoryAggregateType.Average, ct); avg.Samples.Select(s => Convert.ToDouble(s.Value)).ShouldBe([1.5, 3.5, 5.0]); avg.Samples.Select(s => s.SourceTimestampUtc).ShouldBe([At(0), At(2), At(4)]); // The backend refuses Count: the server maps that to BadAggregateNotSupported for the node, and the // driver must raise it (not return empty Good data) so its own server surfaces it per node (#649). // The Average above is the positive control that the same path serves data, and the call must have // REACHED the backend. var ex = await Should.ThrowAsync(() => drv.ReadProcessedAsync(HistRef, At(0), At(6), TimeSpan.FromSeconds(2), HistoryAggregateType.Count, ct)); ex.Aggregate.ShouldBe(HistoryAggregateType.Count); lock (fake.Calls) fake.Calls.ShouldContain($"processed:{HistTag}:Count"); } [Fact] public async Task ReadProcessed_surfaces_any_other_Bad_node_status_instead_of_empty_data() { var ct = TestContext.Current.CancellationToken; var (rig, fake, drv) = await StartAsync("hist-badstatus", ct); await using var _r = rig; await using var _d = drv; var ex = await Should.ThrowAsync(() => drv.ReadProcessedAsync(HistRef, At(0), At(6), TimeSpan.FromSeconds(2), HistoryAggregateType.Minimum, ct)); ex.StatusCode.ShouldBe(BadUserAccessDenied); lock (fake.Calls) fake.Calls.ShouldContain($"processed:{HistTag}:Minimum"); } [Fact] public async Task A_node_level_BadNoData_stays_an_empty_result_not_an_error() { var ct = TestContext.Current.CancellationToken; var (rig, fake, drv) = await StartAsync("hist-badnodata", ct); await using var _r = rig; await using var _d = drv; var result = await drv.ReadProcessedAsync( HistRef, At(0), At(6), TimeSpan.FromSeconds(2), HistoryAggregateType.Maximum, ct); result.Samples.ShouldBeEmpty(); lock (fake.Calls) fake.Calls.ShouldContain($"processed:{HistTag}:Maximum", "the call must have reached the backend"); } [Fact] public async Task A_Good_read_with_no_samples_in_the_window_is_still_empty_data_not_an_error() { var ct = TestContext.Current.CancellationToken; var (rig, _, drv) = await StartAsync("hist-goodnodata", ct); await using var _r = rig; await using var _d = drv; // Historized node, window past the series: the server answers GoodNoData, which must stay a normal empty result. var result = await drv.ReadRawAsync(HistRef, At(100), At(110), 0, ct); result.Samples.ShouldBeEmpty(); result.ContinuationPoint.ShouldBeNull(); } [Fact] public async Task A_read_of_a_non_historized_node_surfaces_its_Bad_status_and_never_reaches_the_historian() { var ct = TestContext.Current.CancellationToken; var (rig, fake, drv) = await StartAsync("hist-none", ct); await using var _r = rig; await using var _d = drv; // The server answers BadHistoryOperationUnsupported for a node with no historian tagname; that Bad // node status is surfaced (#649), no longer read as empty Good data. var ex = await Should.ThrowAsync(() => drv.ReadRawAsync(OpcUaClientDriverWireTests.SpeedRef, At(0), At(10), 0, ct)); ex.StatusCode.ShouldBe(BadHistoryOperationUnsupported); // Positive control: the historized sibling does reach the historian on the same session. (await drv.ReadRawAsync(HistRef, At(0), At(10), 0, ct)).Samples.Count.ShouldBe(5); lock (fake.Calls) fake.Calls.Count.ShouldBe(1, "only the historized read dispatches"); } }