using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser; using SparkplugDataType = Org.Eclipse.Tahu.Protobuf.DataType; namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests; /// /// R06:M-4. sparkplug.birthObservationWindowSeconds was authored, range-checked and /// persisted by the AdminUI and consumed by nothing. It now bounds how long the Sparkplug browse surface /// collects NBIRTH/DBIRTH traffic before calling the observed set stable: the open budget grows by it, /// the open lingers while births are still arriving, and an operator rebirth request waits (bounded) for /// the births it asked for, so the tree the picker re-renders already carries them. /// public sealed class MqttBirthObservationWindowTests { private static MqttDriverOptions Sparkplug(int windowSeconds, int connectTimeoutSeconds = 10) => new() { Mode = MqttMode.SparkplugB, ConnectTimeoutSeconds = connectTimeoutSeconds, Sparkplug = new MqttSparkplugOptions { GroupId = "Plant1", BirthObservationWindowSeconds = windowSeconds }, }; [Fact] public void OpenBudget_Sparkplug_AddsTheBirthObservationWindow() { MqttDriverBrowser.OpenBudget(Sparkplug(windowSeconds: 15)).ShouldBe(TimeSpan.FromSeconds(10 + 15)); MqttDriverBrowser.OpenBudget(Sparkplug(windowSeconds: 40)).ShouldBe(TimeSpan.FromSeconds(10 + 40)); } [Fact] public void OpenBudget_Plain_IgnoresTheSparkplugWindow() { var plain = Sparkplug(windowSeconds: 40) with { Mode = MqttMode.Plain }; MqttDriverBrowser.OpenBudget(plain).ShouldBe(TimeSpan.FromSeconds(10)); } [Theory] [InlineData(0, 1)] [InlineData(-5, 1)] [InlineData(15, 15)] [InlineData(100_000, 120)] public void BirthWindow_IsClamped(int configured, int expectedSeconds) => MqttDriverBrowser.BirthWindow(Sparkplug(configured)).ShouldBe(TimeSpan.FromSeconds(expectedSeconds)); /// /// Review minor 4: the rebirth wait is capped below the AdminUI's 20 s per-call timeout, so a long /// configured window cannot hold the button past it (and a short one still governs). /// [Theory] [InlineData(120, 15)] [InlineData(15, 15)] [InlineData(5, 5)] public async Task RebirthWait_IsTheWindowCappedAt15Seconds(int windowSeconds, int expectedSeconds) { await using var s = new MqttBrowseSession( MqttMode.SparkplugB, client: null, logger: null, birthWindow: TimeSpan.FromSeconds(windowSeconds)); s.RebirthWait.ShouldBe(TimeSpan.FromSeconds(expectedSeconds)); } /// An idle plant costs one quiet interval, not the whole window. [Fact] public async Task Settle_WithNoBirths_ReturnsAfterOneQuietInterval() { var reads = 0; await MqttBrowseSession.SettleBirthsAsync( () => { reads++; return 0; }, baseline: 0, awaitFirstBirth: false, quiet: TimeSpan.FromMilliseconds(20), window: TimeSpan.FromMinutes(5), TestContext.Current.CancellationToken) .WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken); reads.ShouldBe(1); } /// /// While births keep arriving the settle keeps collecting; it returns on the first quiet interval /// after the last one. Counted in reads, not wall-clock, so it cannot flake. /// [Fact] public async Task Settle_KeepsCollectingWhileBirthsArrive_ThenReturnsOnQuiet() { var reads = 0; long births = 0; await MqttBrowseSession.SettleBirthsAsync( () => { reads++; if (reads <= 5) births++; // five intervals with fresh births, then silence return births; }, baseline: 0, awaitFirstBirth: false, quiet: TimeSpan.FromMilliseconds(20), window: TimeSpan.FromMinutes(5), TestContext.Current.CancellationToken) .WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken); reads.ShouldBe(6); } /// The window is a hard ceiling: a birth flood that never goes quiet still ends. [Trait("Category", "SlowUnit")] [Fact] public async Task Settle_NeverQuiet_EndsAtTheWindow() { long births = 0; await MqttBrowseSession.SettleBirthsAsync( () => ++births, baseline: 0, awaitFirstBirth: false, quiet: TimeSpan.FromMilliseconds(20), window: TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken) .WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken); births.ShouldBeGreaterThan(1); } /// /// A rebirth request waits for the birth it asked for. The fixed version cannot complete /// synchronously (it awaits the settle), the unfixed one completes before the call returns — so the /// IsCompleted check is deterministic, not a timing race. The birth then releases it. /// [Fact] public async Task RequestRebirth_WaitsForTheRequestedBirths_ThenReturns() { await using var s = new MqttBrowseSession( MqttMode.SparkplugB, client: null, logger: null, birthWindow: TimeSpan.FromMinutes(5)); var request = s.RequestRebirthAsync("Plant1/EdgeA", TestContext.Current.CancellationToken); request.IsCompleted.ShouldBeFalse("the request must wait for the births it asked for"); s.ObserveBirthForTest("Plant1", "EdgeA", null, "Uptime", SparkplugDataType.Int64); (await request.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken)).ShouldBe(1); s.PublishCountForTest.ShouldBe(1); } /// /// The wait is a courtesy, never a failure: the NCMD was already published, so a caller deadline /// during the settle returns the published count instead of reporting an error for a request that /// succeeded. /// [Fact] public async Task RequestRebirth_CallerCancelsDuringTheSettle_StillReportsThePublish() { await using var s = new MqttBrowseSession( MqttMode.SparkplugB, client: null, logger: null, birthWindow: TimeSpan.FromMinutes(5)); using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); var request = s.RequestRebirthAsync("Plant1/EdgeA", cts.Token); request.IsCompleted.ShouldBeFalse(); await cts.CancelAsync(); (await request.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken)).ShouldBe(1); } }