d67396cc6d
The authored window was persisted and consumed by nothing. It now (clamped 1-120 s) extends the Sparkplug OpenBudget, bounds a passive UntilStable-style settle on open (return after a 1 s quiet spell in NBIRTH/DBIRTH traffic), and bounds how long an operator rebirth request waits for the births it asked for before returning, so the re-rendered tree carries them. A caller deadline during the wait still reports the publish. Refs #744 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
146 lines
6.1 KiB
C#
146 lines
6.1 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// <b>R06:M-4.</b> <c>sparkplug.birthObservationWindowSeconds</c> 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.
|
|
/// </summary>
|
|
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));
|
|
|
|
/// <summary>An idle plant costs one quiet interval, not the whole window.</summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>The window is a hard ceiling: a birth flood that never goes quiet still ends.</summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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
|
|
/// <c>IsCompleted</c> check is deterministic, not a timing race. The birth then releases it.
|
|
/// </summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[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);
|
|
}
|
|
}
|