diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/OtOpcUaServerHostedService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/OtOpcUaServerHostedService.cs index 32af5abd..9ad9c414 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/OtOpcUaServerHostedService.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/OtOpcUaServerHostedService.cs @@ -317,6 +317,9 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl // section is absent; NodeManager is non-null here (guarded above). _server.NodeManager.MaxTieClusterOverfetch = _serverHistorianOptions.MaxTieClusterOverfetch; + // Gitea #691: the ReturnBounds kill switch (default on). + _server.NodeManager.HonorReturnBounds = HonorReturnBounds; + // Propagate the S3 HistoryRead concurrency bounds (arch-review 03/S3): bounded per-node fan-out // within a batch, the process-wide concurrent-batch limiter, and the per-request deadline. Defaults // (4 / 16 / 60 s) survive when the ServerHistorian section is absent. @@ -347,6 +350,12 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl _logger.LogInformation("OtOpcUaServerHostedService: SDK started, address-space + ServiceLevel sinks bound"); } + /// + /// Gitea #691 — the ServerHistorian:HonorReturnBounds kill switch StartAsync applies to the node manager + /// (default true when the section or key is absent). Exposed so the wiring is testable without booting the SDK. + /// + internal bool HonorReturnBounds => _serverHistorianOptions.HonorReturnBounds; + /// /// Gitea #674 — what StartAsync wires onto the node manager for HistoryUpdate: the Primary gate (read from /// the on every call; null when none is registered, i.e. allow), the diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/HistoryBounds.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/HistoryBounds.cs index 120def61..be10d6d9 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/HistoryBounds.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/HistoryBounds.cs @@ -65,6 +65,12 @@ internal static class HistoryBounds public static HistoryEndBound For(DateTime startUtc, DateTime endUtc) => new(endUtc, Reverse: startUtc > endUtc, Inclusive: startUtc != endUtc, Open: IsOpen(endUtc)); + /// Whether a returned value is a placeholder rather than a stored sample. + /// The value. + /// true for a Bad_BoundNotFound placeholder. + public static bool IsPlaceholder(DataValueSnapshot value) => + value.StatusCode == StatusCodes.BadBoundNotFound && value.Value is null; + /// The pending end bound to store behind a continuation point, remembering the page's last returned time. /// The pending end bound (null = bounds not requested). /// The page being returned. @@ -90,7 +96,7 @@ internal static class HistoryBounds { if (IsOpen(startUtc)) return NotFound(startUtc); var found = reverse - ? await EarliestAtOrAfterAsync(source, tagname, startUtc, ct).ConfigureAwait(false) + ? await EarliestAtOrAfterAsync(source, tagname, startUtc, nowUtc, ct).ConfigureAwait(false) : await LatestAtOrBeforeAsync(source, tagname, startUtc, maxTieCluster, nowUtc, ct).ConfigureAwait(false); if (found is null) return NotFound(startUtc); return SameInstant(Ticks(found), startUtc.Ticks, Resolution(source)) ? null : found; @@ -120,13 +126,13 @@ internal static class HistoryBounds if (bound.Reverse) found = await LatestAtOrBeforeAsync(source, tagname, bound.AtUtc, maxTieCluster, nowUtc, ct).ConfigureAwait(false); else if (bound.Inclusive) - found = await EarliestAtOrAfterAsync(source, tagname, bound.AtUtc, ct).ConfigureAwait(false); + found = await EarliestAtOrAfterAsync(source, tagname, bound.AtUtc, nowUtc, ct).ConfigureAwait(false); else { var resolution = Resolution(source); found = bound.AtUtc.Ticks > DateTime.MaxValue.Ticks - resolution ? null - : await EarliestAtOrAfterAsync(source, tagname, bound.AtUtc.AddTicks(resolution), ct).ConfigureAwait(false); + : await EarliestAtOrAfterAsync(source, tagname, bound.AtUtc.AddTicks(resolution), nowUtc, ct).ConfigureAwait(false); } return found ?? NotFound(bound.AtUtc); @@ -172,11 +178,26 @@ internal static class HistoryBounds } /// The earliest stored sample at or after , or null. + /// + /// #691 review: on a backend that reads forward only (the Gateway) the read's end is clamped to now + + /// — the same horizon the walk-back uses — and a time beyond it is "not found" + /// without a read. The HistorianGateway only checks start <= end, so a DateTime.MaxValue end would + /// reach AVEVA as a year-9999 query end (unverified: a rejection, or a fabricated row). The clamp is NOT applied to a + /// backend that reads newest-first natively (SqlServer / QuestDB): they bind MaxValue safely (pinned by the + /// Extreme_bounds conformance test) and can legitimately hold samples further ahead (HistoryUpdate's + /// HistoryUpdateMaxFutureSkew is configurable, and driver-stamped live values carry device time) — clamping + /// there would turn a real end bound into Bad_BoundNotFound. + /// internal static async Task EarliestAtOrAfterAsync( - IHistorianDataSource source, string tagname, DateTime atUtc, CancellationToken ct) + IHistorianDataSource source, string tagname, DateTime atUtc, DateTime nowUtc, CancellationToken ct) { - if (atUtc.Ticks >= DateTime.MaxValue.Ticks) return null; - var rows = (await source.ReadRawAsync(tagname, atUtc, new DateTime(DateTime.MaxValue.Ticks, DateTimeKind.Utc), 1, ct) + var endTicks = DateTime.MaxValue.Ticks; + if (!source.SupportsReverseRaw) + endTicks = nowUtc.Ticks > DateTime.MaxValue.Ticks - ReverseRawReader.LookAhead.Ticks + ? DateTime.MaxValue.Ticks + : nowUtc.Ticks + ReverseRawReader.LookAhead.Ticks; + if (atUtc.Ticks >= endTicks) return null; + var rows = (await source.ReadRawAsync(tagname, atUtc, new DateTime(endTicks, DateTimeKind.Utc), 1, ct) .ConfigureAwait(false)).Samples; var limit = Align(atUtc.Ticks, Resolution(source)); foreach (var row in rows) diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs index 0913e033..eb0c81d8 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs @@ -544,6 +544,13 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 /// public int MaxTieClusterOverfetch { get; set; } = 65536; + /// + /// Gitea #691: whether HistoryRead-Raw honours ReturnBounds (Part 11 bounding values, ) + /// — ServerHistorian:HonorReturnBounds, default true. false ignores the flag, as before #691, + /// including the modified-read Bad_InvalidArgument. + /// + public bool HonorReturnBounds { get; set; } = true; + /// /// Maximum historized nodes served concurrently within one HistoryRead batch (arch-review 03/S3). /// Mirrors ServerHistorianOptions.HistoryReadBatchParallelism; the Host sets it at @@ -5056,10 +5063,10 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 var work = new List>(nodesToProcess.Count); foreach (var handle in nodesToProcess) { - if (details.IsReadModified && details.ReturnBounds) + if (details.IsReadModified && details.ReturnBounds && HonorReturnBounds) { // Part 11 §6.5.3.3: returnBounds "shall be set to FALSE" for a modified read, "otherwise the Server returns - // a Bad_InvalidArgument" (#691). + // a Bad_InvalidArgument" (#691). Off with the ServerHistorian:HonorReturnBounds kill switch. errors[handle.Index] = StatusCodes.BadInvalidArgument; results[handle.Index] = new SdkHistoryReadResult { StatusCode = StatusCodes.BadInvalidArgument }; continue; @@ -5079,7 +5086,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 work.Add(() => ServeRawPagedAsync( handle, session, nodesToRead, results, errors, details.StartTime, details.EndTime, details.NumValuesPerNode, - ct, details.ReturnBounds)); + ct, details.ReturnBounds && HonorReturnBounds)); } RunBounded(work, HistoryReadBatchParallelism); @@ -6306,6 +6313,21 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 PendingEndBound: HistoryBounds.Carry(endBound!, page)! with { DataExhausted = true })); } + // #691 review: whether a bounded read whose start bound was NOT found has any data or a found end bound — only + // asked when a one-value page would otherwise be the start-bound placeholder alone. + async Task BoundedReadHasAnythingAsync() + { + var src = HistorianDataSource; + IReadOnlyList first = startUtc > endUtc && !src.SupportsReverseRaw + ? (await ReverseRawReader.ReadAsync(src, tagname, startUtc, endUtc, 1, MaxTieClusterOverfetch, nowUtc, 0, ct) + .ConfigureAwait(false)).Samples + : (await src.ReadRawAsync(tagname, startUtc, endUtc, 1, ct).ConfigureAwait(false)).Samples; + if (first.Any(x => HistoryBounds.InDataWindow(x, endBound!, resolution))) return true; + var end = await HistoryBounds.EndBoundAsync( + src, tagname, endBound!, startUtc, MaxTieClusterOverfetch, nowUtc, ct).ConfigureAwait(false); + return !HistoryBounds.IsPlaceholder(end); + } + // #691: a resumed bounded read whose data is drained (the end bound did not fit on the previous page, or the // resume point already sits on the end time) owes only the end bound. if (endBound is not null && inboundCp is { Length: > 0 } @@ -6332,7 +6354,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 startBound = await HistoryBounds.StartBoundAsync( HistorianDataSource, tagname, startUtc, endBound.Reverse, MaxTieClusterOverfetch, nowUtc, ct) .ConfigureAwait(false); - if (startBound is not null && numValuesPerNode == 1) + if (startBound is not null && numValuesPerNode == 1 + && (!HistoryBounds.IsPlaceholder(startBound) || await BoundedReadHasAnythingAsync().ConfigureAwait(false))) { // The page is the start bound alone; the data starts at StartTime on the next page. var boundOnlyCp = _historyContinuationStore.Save(session, new HistoryContinuationState( @@ -6349,6 +6372,19 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 return; } + if (startBound is not null && numValuesPerNode == 1) + { + // Nothing at all — no data and no bound found (review of #691): the pre-bounds GoodNoData answer. + results[handle.Index] = new SdkHistoryReadResult + { + StatusCode = StatusCodes.GoodNoData, + HistoryData = new ExtensionObject(ToHistoryDataFromSamples([])), + ContinuationPoint = null, + }; + errors[handle.Index] = ServiceResult.Good; + return; + } + if (startBound is not null && numValuesPerNode > 0) readCap = numValuesPerNode - 1; } @@ -6510,6 +6546,15 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 historyData = ToHistoryDataFromSamples(samples); } + // #691 review: a whole bounded read with no data and no bound found (one page, every value a placeholder) is the + // pre-bounds answer — GoodNoData, no values (§6.5.3.2: bounds make it a success only "if one or both exist"). + if (endBound is not null && inboundCp is not { Length: > 0 } && outboundCp is null + && samples.All(HistoryBounds.IsPlaceholder)) + { + samples = []; + historyData = ToHistoryDataFromSamples(samples); + } + results[handle.Index] = new SdkHistoryReadResult { // No samples ⇒ GoodNoData (the node is historized, the window just held no data). With the diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/ServerHistorianOptions.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/ServerHistorianOptions.cs index 7c04110d..37c04091 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/ServerHistorianOptions.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/ServerHistorianOptions.cs @@ -89,6 +89,14 @@ public sealed class ServerHistorianOptions /// public int MaxTieClusterOverfetch { get; init; } = 65536; + /// + /// Gitea #691: whether HistoryRead-Raw honours ReadRawModifiedDetails.ReturnBounds (OPC UA Part 11 bounding + /// values). Default true. false is the kill switch back to the pre-#691 answer: the flag is ignored + /// (stored samples only) and a modified read carrying it is not refused Bad_InvalidArgument. Note the .NET + /// SDK's new ReadRawModifiedDetails() defaults ReturnBounds (and IsReadModified) to true. + /// + public bool HonorReturnBounds { get; init; } = true; + /// /// Maximum number of historized nodes served CONCURRENTLY within one HistoryRead batch (arch-review /// 03/S3). The SDK's HistoryRead* override surface is synchronous, so each arm block-bridges diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayBoundingValueTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayBoundingValueTests.cs index 7bea7f50..0b855923 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayBoundingValueTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayBoundingValueTests.cs @@ -40,14 +40,28 @@ public sealed class GatewayBoundingValueTests Quality = isNull ? 1u : 0u, }; - /// ReadRaw over [start, end] ascending, capped, led by AVEVA's boundary row at the query start. - private static FakeHistorianGatewayClient Aveva() + /// The latest query end any ReadRaw was sent (a far-future end is the #691 review's risk). + private sealed class EndProbe + { + public DateTime MaxEnd = DateTime.MinValue; + } + + /// + /// ReadRaw over [start, end] ascending, capped, led by AVEVA's boundary row at the query start. A query end more + /// than two days past is REFUSED (InvalidArgument), modelling the unverified AVEVA behaviour + /// for a year-9999 end (the HistorianGateway only checks start <= end, so it reaches AVEVA unclamped). + /// + private static FakeHistorianGatewayClient Aveva(EndProbe? probe = null) { var sorted = Gate681.Select(x => (T: At(x.Sec), V: x.Value, x.Null)).ToList(); return new FakeHistorianGatewayClient { RawSource = (start, end, max) => { + if (probe is not null && end > probe.MaxEnd) probe.MaxEnd = end; + if (end > Now.AddDays(2)) + throw new Grpc.Core.RpcException(new Grpc.Core.Status(Grpc.Core.StatusCode.InvalidArgument, + "end_time is outside the historian's time range")); var rows = new List(); if (sorted.All(d => d.T != start)) { @@ -124,6 +138,30 @@ public sealed class GatewayBoundingValueTests missing.SourceTimestampUtc.ShouldBe(At(101)); } + /// + /// #691 review: the at-or-after read never sends an end beyond now + (the + /// historian stores nothing further ahead), and an end bound wanted beyond that horizon is not found without a read. + /// + [Fact] + public async Task The_at_or_after_read_never_asks_past_now_plus_one_day() + { + var probe = new EndProbe(); + var fake = Aveva(probe); + var source = Source(fake); + + var found = await HistoryBounds.EndBoundAsync(source, "T", HistoryBounds.For(At(0), At(41)), At(40), 65536, Now, Ct); + found.SourceTimestampUtc.ShouldBe(At(50)); + probe.MaxEnd.ShouldBeLessThanOrEqualTo(Now + ReverseRawReader.LookAhead.Add(TimeSpan.FromMilliseconds(1))); + + var calls = fake.ReadRawCallCount; + var beyond = Now.AddDays(3); + var missing = await HistoryBounds.EndBoundAsync(source, "T", HistoryBounds.For(At(0), beyond), At(100), 65536, Now, Ct); + missing.StatusCode.ShouldBe(Opc.Ua.StatusCodes.BadBoundNotFound); + missing.SourceTimestampUtc.ShouldBe(beyond); + fake.ReadRawCallCount.ShouldBe(calls, "a bound beyond the horizon is not found without a read"); + source.GetHealthSnapshot().TotalFailures.ShouldBe(0); + } + [Fact] public async Task A_reverse_read_end_bound_walks_back_to_the_stored_sample_at_or_before_the_end_time() { diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/OpcUa/OtOpcUaServerHostedServiceHistoryUpdateTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/OpcUa/OtOpcUaServerHostedServiceHistoryUpdateTests.cs index 9b3e9dc5..37461249 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/OpcUa/OtOpcUaServerHostedServiceHistoryUpdateTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/OpcUa/OtOpcUaServerHostedServiceHistoryUpdateTests.cs @@ -72,6 +72,18 @@ public sealed class OtOpcUaServerHostedServiceHistoryUpdateTests Service(view: null).BuildHistoryUpdatePosture().MaxFutureSkew.ShouldBe(TimeSpan.FromDays(1)); } + /// #691: the ReturnBounds kill switch is bound from ServerHistorian:HonorReturnBounds, default on. + [Fact] + public void The_return_bounds_kill_switch_comes_from_options_and_defaults_on() + { + Service(view: null).HonorReturnBounds.ShouldBeTrue(); + var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary + { + ["ServerHistorian:HonorReturnBounds"] = "false", + }).Build(); + Service(view: null, config: config).HonorReturnBounds.ShouldBeFalse(); + } + private static OtOpcUaServerHostedService Service( IHistoryUpdateRoleView? view, IDriverPrimaryRoleSource? primary = null, IConfiguration? config = null, TimeProvider? clock = null) => new( diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadBoundsTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadBoundsTests.cs index ebee310b..8b618b9e 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadBoundsTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadBoundsTests.cs @@ -292,6 +292,71 @@ public sealed class NodeManagerHistoryReadBoundsTests(BoundsServerFixture fx) : Render(values, T("5:00"), T("5:05")).ShouldBe("5:00, 5:02, 5:03, 5:05"); } + /// + /// #691 review: no data AND no bound found ⇒ GoodNoData with no values — the answer a tag with no history (and a + /// disabled historian) gave before bounds (§6.5.3.2: bounds make the result Good only "if one or both exist"). Only + /// when the WHOLE read is empty: a single bound found keeps the placeholders (Table 1: "4:48 4:48 1 ⇒ FIRST"). + /// + [Theory] + [InlineData(Shape.Reverse)] + [InlineData(Shape.ForwardOnly)] + [InlineData(Shape.Microsecond)] + public async Task No_data_and_no_bound_found_is_GoodNoData_with_no_values(Shape shape) + { + var windows = new (string S, string E)[] { ("5:00", "5:05"), ("5:05", "5:00"), ("5:00", "5:00"), ("5:00", ""), ("", "5:05") }; + foreach (var (start, end) in windows) + { + var s = start.Length == 0 ? Unspecified : T(start); + var e = end.Length == 0 ? Unspecified : T(end); + foreach (var n in new uint[] { 0, 1, 2, 3 }) + { + if (n == 0 && (s == Unspecified || e == Unspecified)) continue; // a one-sided read needs a cap + var (values, error, cp) = await ReadResultAsync(Source(shape, []), s, e, n); + values.ShouldBeEmpty($"{start}->{end} by {n}"); + cp.ShouldBeNull(); + error.StatusCode.Code.ShouldBe(StatusCodes.Good); + LastNodeStatus.Code.ShouldBe(StatusCodes.GoodNoData, $"{start}->{end} by {n}"); + } + } + + // Positive control: one bound found keeps the missing one's placeholder. + var single = await DrainAsync(Source(shape, TableData()), T("4:48"), T("4:48"), 1, bounds: true); + Render(single, T("4:48"), T("4:48")).ShouldBe("FIRST, 5:00"); + } + + /// + /// #691 review: ServerHistorian:HonorReturnBounds = false restores the pre-#691 answer — ReturnBounds is ignored + /// (Table 1's "No" rows for every "Yes" row) and a modified read with ReturnBounds is not refused for it. + /// + [Fact] + public async Task The_kill_switch_ignores_return_bounds() + { + await fx.Gate.WaitAsync(TestContext.Current.CancellationToken); + try + { + fx.Nm.HonorReturnBounds = false; + fx.Nm.HistorianDataSource = Source(Shape.Reverse, TableData()); + var (values, _, _) = Read(T("5:01"), T("5:07"), 0, bounds: true, cp: null, modified: false); + Render(values, T("5:01"), T("5:07")).ShouldBe("5:02, 5:03, 5:05, 5:06"); + + var (_, modErr, _) = Read(T("5:00"), T("5:05"), 0, bounds: true, cp: null, modified: true); + modErr.StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported, "no trail on this fake — not InvalidArgument"); + } + finally + { + fx.Nm.HonorReturnBounds = true; + fx.Gate.Release(); + } + } + + [Fact] + public async Task Bounds_are_honoured_by_default() + { + await fx.Gate.WaitAsync(TestContext.Current.CancellationToken); + try { fx.Nm.HonorReturnBounds.ShouldBeTrue(); } + finally { fx.Gate.Release(); } + } + /// Part 11 §6.5.3.3: a modified read with ReturnBounds is Bad_InvalidArgument. [Fact] public async Task A_modified_read_with_return_bounds_is_an_invalid_argument() @@ -357,6 +422,13 @@ public sealed class NodeManagerHistoryReadBoundsTests(BoundsServerFixture fx) : return all; } + /// The node-level StatusCode of the last (GoodNoData vs Good). + private StatusCode LastNodeStatus; + + private async Task<(IReadOnlyList Values, ServiceResult Error, byte[]? Cp)> ReadResultAsync( + IHistorianDataSource source, DateTime start, DateTime end, uint max) => + await ReadAsync(source, start, end, max, bounds: true, cp: null); + private async Task<(IReadOnlyList Values, ServiceResult Error, byte[]? Cp)> ReadAsync( IHistorianDataSource source, DateTime start, DateTime end, uint max, bool bounds, byte[]? cp) { @@ -381,6 +453,7 @@ public sealed class NodeManagerHistoryReadBoundsTests(BoundsServerFixture fx) : var results = new List { null! }; var errors = new List { null! }; fx.Nm.HistoryRead(context, details, TimestampsToReturn.Both, releaseContinuationPoints: false, nodesToRead, results, errors); + LastNodeStatus = results[0]?.StatusCode ?? StatusCodes.Bad; var values = new List(); if (results[0]?.HistoryData is { } ho && !ExtensionObject.IsNull(ho)) diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Historian/ServerHistorianOptionsTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Historian/ServerHistorianOptionsTests.cs index 767ec21f..40bea597 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Historian/ServerHistorianOptionsTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Historian/ServerHistorianOptionsTests.cs @@ -104,6 +104,21 @@ public sealed class ServerHistorianOptionsTests /// #674: the HistoryUpdate future-skew bound defaults to one day and binds from configuration as a TimeSpan; /// a non-positive value is a deliberate "disabled", not a misconfiguration, so it does not warn. /// + /// #691: the ReturnBounds kill switch defaults on and binds off; neither value warns. + [Fact] + public void HonorReturnBounds_defaults_to_true_and_binds_false_silently() + { + Assert.True(new ServerHistorianOptions().HonorReturnBounds); + var bound = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary { ["ServerHistorian:HonorReturnBounds"] = "false" }) + .Build() + .GetSection(ServerHistorianOptions.SectionName) + .Get(); + Assert.False(bound!.HonorReturnBounds); + Assert.Empty(new ServerHistorianOptions + { Enabled = true, Endpoint = "https://h:5222", ApiKey = "histgw_x_y", HonorReturnBounds = false }.Validate()); + } + [Fact] public void HistoryUpdateMaxFutureSkew_defaults_to_one_day_binds_and_zero_is_silent() {