8d9155682d
Two gaps the Task 23/24 review found, both blocking Task 26's live gate.
Gap 1 — browse-commit produced a silently dead MQTT tag. RawBrowseCommitMapper
had no `Mqtt` case, so a committed leaf fell through to the generic
`{"address": …}` key. Neither MqttTagDefinitionFactory entry point reads
`address`: the tag deployed clean and reported BadNodeIdUnknown forever, with
no signal at commit time. Predates Task 23 (Plain was affected too), but Task 23
built the Sparkplug metric tree precisely so an operator could browse and commit
a binding.
The address is a DESCRIPTOR, not a reference string, and it cannot be recovered
from the browse node id: `{group}/{node}[/{device}]::{metric}` where a metric
name legitimately contains `/` (`Node Control/Rebirth`) — the ambiguity
MetricSeparator's remarks already name. So the session STATES it, via a new
`AttributeInfo.AddressFields` seam, and the mapper reads it. Which keys are
emitted is also how the mapper learns Plain vs Sparkplug — the driver type
reaching it is just `Mqtt`, and the mode lives on the driver config. Key names
are single-sourced in the new `MqttTagConfigKeys` (producer, mapper, factory),
with the literals pinned by a test so a symmetric rename cannot silently unbind
already-persisted blobs. A leaf with no stated address is refused at commit in
words rather than committed dead.
Gap 2 — RequestRebirthAsync had no UI. Task 23 shipped it backend-only; Task 26
step 1 assumes the button, and Task 25's runbook notes the picker tree stays
empty until a birth lands, so it is the only way to fill it on demand. Added to
the /raw browse modal: scope = the clicked tree node (device/metric resolve up to
their edge node, group fans out and is refused whole past 32), an explicit
two-click confirm naming the resolved scope and its consequence, the outcome or
the refusal shown rather than swallowed. Offered only for a Sparkplug session
(new `IRebirthCapableBrowseSession.RebirthAvailable` — one session class serves
both modes, so a type test alone would draw a button that can only throw) and
only to a DriverOperator; the server-side gate remains the boundary.
Tests: MQTT 545 → 581, AdminUI 781 → 800. The round-trip tests feed the emitted
blob to the REAL factory and were falsified by mutating the emitted key name.
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
896 lines
42 KiB
C#
896 lines
42 KiB
C#
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
|
|
using ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser;
|
|
|
|
// See SparkplugTopicTests.cs for why this alias is redeclared locally in every consuming project
|
|
// rather than inherited: `SparkplugDataType` is a `global using` alias for the generated
|
|
// `Org.Eclipse.Tahu.Protobuf.DataType` (declared in Contracts/SparkplugDataType.cs), and `global using`
|
|
// scope does not cross a ProjectReference.
|
|
using SparkplugDataType = Org.Eclipse.Tahu.Protobuf.DataType;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests;
|
|
|
|
/// <summary>
|
|
/// Covers the passive <c>#</c>-observation browse session and the browser that opens it. The
|
|
/// session is exercised through its test seam (<c>ObserveTopicForTest</c>) rather than a live
|
|
/// broker: the accumulating tree is the whole of the P1 logic, and the MQTTnet plumbing that
|
|
/// feeds it is a one-line handler.
|
|
/// </summary>
|
|
public sealed class MqttBrowseSessionTests
|
|
{
|
|
// ---------------------------------------------------------------- tree shape
|
|
|
|
[Fact]
|
|
public async Task ExpandAsync_BuildsTopicSegmentTree_FromObservedTopics()
|
|
{
|
|
await using var s = new MqttBrowseSession(MqttMode.Plain);
|
|
s.ObserveTopicForTest("factory/line3/oven/temp");
|
|
|
|
var root = await s.RootAsync(TestContext.Current.CancellationToken);
|
|
root.ShouldContain(n => n.DisplayName == "factory");
|
|
|
|
var lvl2 = await s.ExpandAsync("factory", TestContext.Current.CancellationToken);
|
|
lvl2.ShouldContain(n => n.DisplayName == "line3");
|
|
|
|
var lvl3 = await s.ExpandAsync("factory/line3", TestContext.Current.CancellationToken);
|
|
lvl3.ShouldContain(n => n.DisplayName == "oven");
|
|
|
|
var lvl4 = await s.ExpandAsync("factory/line3/oven", TestContext.Current.CancellationToken);
|
|
var leaf = lvl4.ShouldHaveSingleItem();
|
|
leaf.DisplayName.ShouldBe("temp");
|
|
leaf.NodeId.ShouldBe("factory/line3/oven/temp");
|
|
leaf.Kind.ShouldBe(BrowseNodeKind.Leaf);
|
|
leaf.HasChildrenHint.ShouldBeFalse();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ObservedTopics_AccumulateAcrossMessages_AndDoNotDuplicate()
|
|
{
|
|
await using var s = new MqttBrowseSession(MqttMode.Plain);
|
|
s.ObserveTopicForTest("factory/line3/oven/temp");
|
|
s.ObserveTopicForTest("factory/line3/oven/temp"); // repeat — must not duplicate
|
|
s.ObserveTopicForTest("factory/line3/oven/pressure");
|
|
s.ObserveTopicForTest("factory/line4/mixer/rpm");
|
|
s.ObserveTopicForTest("plant/power");
|
|
|
|
var root = await s.RootAsync(TestContext.Current.CancellationToken);
|
|
root.Select(n => n.DisplayName).ShouldBe(["factory", "plant"]); // sorted, deduped
|
|
|
|
var lines = await s.ExpandAsync("factory", TestContext.Current.CancellationToken);
|
|
lines.Select(n => n.DisplayName).ShouldBe(["line3", "line4"]);
|
|
|
|
var oven = await s.ExpandAsync("factory/line3/oven", TestContext.Current.CancellationToken);
|
|
oven.Select(n => n.DisplayName).ShouldBe(["pressure", "temp"]);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task IntermediateNode_IsFolder_WithChildrenHint()
|
|
{
|
|
await using var s = new MqttBrowseSession(MqttMode.Plain);
|
|
s.ObserveTopicForTest("factory/line3/oven/temp");
|
|
|
|
var root = await s.RootAsync(TestContext.Current.CancellationToken);
|
|
var factory = root.ShouldHaveSingleItem();
|
|
factory.Kind.ShouldBe(BrowseNodeKind.Folder);
|
|
factory.HasChildrenHint.ShouldBeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ExpandAsync_UnknownNode_ReturnsEmpty()
|
|
{
|
|
await using var s = new MqttBrowseSession(MqttMode.Plain);
|
|
s.ObserveTopicForTest("factory/line3");
|
|
|
|
var children = await s.ExpandAsync("nope/not/here", TestContext.Current.CancellationToken);
|
|
children.ShouldBeEmpty();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RootAsync_BeforeAnyTraffic_IsEmpty()
|
|
{
|
|
// MQTT has no discovery protocol: an observation window that has seen nothing shows nothing.
|
|
await using var s = new MqttBrowseSession(MqttMode.Plain);
|
|
(await s.RootAsync(TestContext.Current.CancellationToken)).ShouldBeEmpty();
|
|
}
|
|
|
|
// ---------------------------------------------------------------- read-only guarantee
|
|
|
|
[Fact]
|
|
public async Task BrowseCalls_PublishNothing() // browse is read-only
|
|
{
|
|
await using var s = new MqttBrowseSession(MqttMode.Plain);
|
|
s.ObserveTopicForTest("factory/line3/oven/temp", "23.5");
|
|
|
|
await s.RootAsync(TestContext.Current.CancellationToken);
|
|
await s.ExpandAsync("factory", TestContext.Current.CancellationToken);
|
|
await s.AttributesAsync("factory/line3/oven/temp", TestContext.Current.CancellationToken);
|
|
|
|
s.PublishCountForTest.ShouldBe(0);
|
|
}
|
|
|
|
// ---------------------------------------------------------------- attributes
|
|
|
|
[Theory]
|
|
[InlineData("23.5", nameof(Core.Abstractions.DriverDataType.Float64))]
|
|
[InlineData("42", nameof(Core.Abstractions.DriverDataType.Int64))]
|
|
[InlineData("true", nameof(Core.Abstractions.DriverDataType.Boolean))]
|
|
[InlineData("{\"v\":1}", nameof(Core.Abstractions.DriverDataType.String))]
|
|
public async Task AttributesAsync_InfersTypeFromLastPayload(string payload, string expectedType)
|
|
{
|
|
await using var s = new MqttBrowseSession(MqttMode.Plain);
|
|
s.ObserveTopicForTest("factory/oven/temp", payload);
|
|
|
|
var attrs = await s.AttributesAsync("factory/oven/temp", TestContext.Current.CancellationToken);
|
|
var a = attrs.ShouldHaveSingleItem();
|
|
a.Name.ShouldBe("temp");
|
|
a.DriverDataType.ShouldBe(expectedType);
|
|
a.IsArray.ShouldBeFalse();
|
|
a.IsAlarm.ShouldBeFalse();
|
|
a.SecurityClass.ShouldContain(payload); // the last-payload snippet
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AttributesAsync_LongPayload_IsTruncatedToASnippet()
|
|
{
|
|
await using var s = new MqttBrowseSession(MqttMode.Plain);
|
|
s.ObserveTopicForTest("t/long", new string('x', 500));
|
|
|
|
var a = (await s.AttributesAsync("t/long", TestContext.Current.CancellationToken)).ShouldHaveSingleItem();
|
|
a.SecurityClass.Length.ShouldBeLessThanOrEqualTo(MqttBrowseSession.PayloadSnippetMaxChars + 1);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AttributesAsync_UnobservedFolder_ReturnsEmpty()
|
|
{
|
|
await using var s = new MqttBrowseSession(MqttMode.Plain);
|
|
s.ObserveTopicForTest("factory/oven/temp", "1");
|
|
|
|
// "factory" is a synthesised path segment — nothing was ever published to it.
|
|
(await s.AttributesAsync("factory", TestContext.Current.CancellationToken)).ShouldBeEmpty();
|
|
(await s.AttributesAsync("no/such/node", TestContext.Current.CancellationToken)).ShouldBeEmpty();
|
|
}
|
|
|
|
// ---------------------------------------------------------------- concurrency
|
|
|
|
[Fact]
|
|
public async Task ObserveDuringBrowse_IsThreadSafe()
|
|
{
|
|
// Messages arrive on MQTTnet's dispatcher thread while the picker calls ExpandAsync.
|
|
await using var s = new MqttBrowseSession(MqttMode.Plain);
|
|
using var stop = new CancellationTokenSource();
|
|
|
|
var observed = 0;
|
|
var writer = Task.Run(() =>
|
|
{
|
|
var i = 0;
|
|
while (!stop.Token.IsCancellationRequested)
|
|
{
|
|
s.ObserveTopicForTest($"factory/line{i % 8}/dev{i % 32}/m{i}", i.ToString());
|
|
Interlocked.Increment(ref observed);
|
|
i++;
|
|
}
|
|
}, CancellationToken.None);
|
|
|
|
// The read loop is pure in-memory work and would otherwise finish before the writer thread
|
|
// is even scheduled, leaving the two never actually overlapping.
|
|
while (Volatile.Read(ref observed) == 0) await Task.Yield();
|
|
|
|
for (var i = 0; i < 400; i++)
|
|
{
|
|
await s.RootAsync(TestContext.Current.CancellationToken);
|
|
await s.ExpandAsync("factory", TestContext.Current.CancellationToken);
|
|
await s.ExpandAsync("factory/line3", TestContext.Current.CancellationToken);
|
|
}
|
|
|
|
await stop.CancelAsync();
|
|
await writer; // rethrows anything the observer thread hit
|
|
|
|
(await s.RootAsync(TestContext.Current.CancellationToken)).ShouldNotBeEmpty();
|
|
}
|
|
|
|
// ---------------------------------------------------------------- lifetime
|
|
|
|
[Fact]
|
|
public async Task LastUsedUtc_AdvancesOnEveryCall()
|
|
{
|
|
var s = new MqttBrowseSession(MqttMode.Plain);
|
|
var before = s.LastUsedUtc;
|
|
await Task.Delay(15, TestContext.Current.CancellationToken);
|
|
|
|
await s.RootAsync(TestContext.Current.CancellationToken);
|
|
s.LastUsedUtc.ShouldBeGreaterThan(before);
|
|
|
|
var afterRoot = s.LastUsedUtc;
|
|
await Task.Delay(15, TestContext.Current.CancellationToken);
|
|
await s.ExpandAsync("x", TestContext.Current.CancellationToken);
|
|
s.LastUsedUtc.ShouldBeGreaterThan(afterRoot);
|
|
|
|
await s.DisposeAsync();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DisposeAsync_OnNeverOpenedSession_IsIdempotentAndDoesNotThrow()
|
|
{
|
|
var s = new MqttBrowseSession(MqttMode.Plain);
|
|
await Should.NotThrowAsync(async () => await s.DisposeAsync());
|
|
await Should.NotThrowAsync(async () => await s.DisposeAsync());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AfterDispose_BrowseCallsThrowObjectDisposed()
|
|
{
|
|
var s = new MqttBrowseSession(MqttMode.Plain);
|
|
s.ObserveTopicForTest("a/b");
|
|
await s.DisposeAsync();
|
|
|
|
await Should.ThrowAsync<ObjectDisposedException>(
|
|
async () => await s.RootAsync(TestContext.Current.CancellationToken));
|
|
await Should.ThrowAsync<ObjectDisposedException>(
|
|
async () => await s.ExpandAsync("a", TestContext.Current.CancellationToken));
|
|
await Should.ThrowAsync<ObjectDisposedException>(
|
|
async () => await s.AttributesAsync("a/b", TestContext.Current.CancellationToken));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ObserveAfterDispose_IsIgnored()
|
|
{
|
|
// A message already in MQTTnet's dispatcher when the reaper disposes the session.
|
|
var s = new MqttBrowseSession(MqttMode.Plain);
|
|
await s.DisposeAsync();
|
|
Should.NotThrow(() => s.ObserveTopicForTest("late/arrival", "1"));
|
|
}
|
|
|
|
// ---------------------------------------------------------------- node cap
|
|
|
|
[Fact]
|
|
public async Task NodeCap_StopsRecording_AndSurfacesATruncationMarker()
|
|
{
|
|
await using var s = new MqttBrowseSession(MqttMode.Plain, nodeCap: 4);
|
|
s.ObserveTopicForTest("a/b/c"); // 3 nodes
|
|
s.ObserveTopicForTest("d/e/f"); // would take it past 4
|
|
|
|
var root = await s.RootAsync(TestContext.Current.CancellationToken);
|
|
root.ShouldContain(n => n.NodeId == MqttBrowseSession.TruncatedNodeId);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task OversizedTopic_IsRejectedWhole_AndSurfacesAMarker()
|
|
{
|
|
// A truncated topic is a DIFFERENT topic — the picker would commit it as a tag address.
|
|
// Every segment here is 4 chars, well under MaxSegmentChars, so ONLY the whole-topic bound
|
|
// can reject it — the segment guard must not be what makes this test pass.
|
|
await using var s = new MqttBrowseSession(MqttMode.Plain);
|
|
s.ObserveTopicForTest("good/topic", "1");
|
|
|
|
var manyShortSegments = "huge/" + string.Join('/', Enumerable.Repeat("abc", MqttBrowseSession.MaxTopicChars / 4 + 8));
|
|
manyShortSegments.Length.ShouldBeGreaterThan(MqttBrowseSession.MaxTopicChars);
|
|
manyShortSegments.Split('/').ShouldAllBe(seg => seg.Length <= MqttBrowseSession.MaxSegmentChars);
|
|
s.ObserveTopicForTest(manyShortSegments, "1");
|
|
|
|
var root = await s.RootAsync(TestContext.Current.CancellationToken);
|
|
root.ShouldContain(n => n.NodeId == MqttBrowseSession.OversizedNodeId);
|
|
root.ShouldContain(n => n.DisplayName == "good");
|
|
root.ShouldNotContain(n => n.DisplayName == "huge"); // no half-built path left behind
|
|
}
|
|
|
|
[Fact]
|
|
public async Task OversizedSegment_IsRejectedWhole_EvenWhenTheTopicFits()
|
|
{
|
|
await using var s = new MqttBrowseSession(MqttMode.Plain);
|
|
s.ObserveTopicForTest("a/" + new string('y', MqttBrowseSession.MaxSegmentChars + 1) + "/b", "1");
|
|
|
|
s.OversizedRejected.ShouldBeTrue();
|
|
(await s.ExpandAsync("a", TestContext.Current.CancellationToken)).ShouldBeEmpty();
|
|
var root = await s.RootAsync(TestContext.Current.CancellationToken);
|
|
root.ShouldNotContain(n => n.DisplayName == "a");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task TopicAtExactlyTheCap_IsAccepted()
|
|
{
|
|
await using var s = new MqttBrowseSession(MqttMode.Plain);
|
|
s.ObserveTopicForTest(new string('z', MqttBrowseSession.MaxSegmentChars), "1");
|
|
|
|
s.OversizedRejected.ShouldBeFalse();
|
|
(await s.RootAsync(TestContext.Current.CancellationToken)).ShouldHaveSingleItem();
|
|
}
|
|
|
|
// ---------------------------------------------------------------- payload bounds
|
|
|
|
[Fact]
|
|
public async Task PayloadBeyondTheInspectionWindow_IsNeverExamined()
|
|
{
|
|
// The discriminating test for the byte cap: content past PayloadInspectMaxBytes cannot
|
|
// influence the result at all. With an unbounded decode the trailing scalar would be found
|
|
// and typed Int64, and would show up in the snippet.
|
|
await using var s = new MqttBrowseSession(MqttMode.Plain);
|
|
s.ObserveTopicForTest(
|
|
"t/tail",
|
|
new string(' ', MqttBrowseSession.PayloadInspectMaxBytes + 64) + "42");
|
|
|
|
var a = (await s.AttributesAsync("t/tail", TestContext.Current.CancellationToken)).ShouldHaveSingleItem();
|
|
a.DriverDataType.ShouldBe(nameof(Core.Abstractions.DriverDataType.String));
|
|
a.SecurityClass.ShouldNotContain("42");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task HugePayload_YieldsABoundedSnippetMarkedAsClipped()
|
|
{
|
|
// A '#' subscription receives multi-MB retained blobs; the node cap never engages for a
|
|
// large payload on an already-known topic, so the decode itself must be bounded.
|
|
await using var s = new MqttBrowseSession(MqttMode.Plain);
|
|
s.ObserveTopicForTest("t/blob", new string('a', 5_000_000));
|
|
|
|
var a = (await s.AttributesAsync("t/blob", TestContext.Current.CancellationToken)).ShouldHaveSingleItem();
|
|
a.SecurityClass.Length.ShouldBeLessThanOrEqualTo(MqttBrowseSession.PayloadSnippetMaxChars + 1);
|
|
a.SecurityClass.ShouldEndWith("…");
|
|
a.DriverDataType.ShouldBe(nameof(Core.Abstractions.DriverDataType.String));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ClippedMultiByteUtf8_IsNotMislabelledAsBinary()
|
|
{
|
|
// The inspection window can land mid-sequence; backing off to a whole sequence keeps a
|
|
// perfectly good UTF-8 payload from being reported as binary.
|
|
await using var s = new MqttBrowseSession(MqttMode.Plain);
|
|
|
|
// 3 bytes per char, so 512 / 3 leaves a partial sequence at the window edge.
|
|
s.ObserveTopicForTest("t/utf8", new string('☃', 4000));
|
|
|
|
var a = (await s.AttributesAsync("t/utf8", TestContext.Current.CancellationToken)).ShouldHaveSingleItem();
|
|
a.SecurityClass.ShouldStartWith("☃");
|
|
a.SecurityClass.ShouldNotContain("binary");
|
|
a.SecurityClass.ShouldNotContain("�"); // no replacement char from a split sequence
|
|
}
|
|
|
|
[Fact]
|
|
public async Task BinaryPayload_IsReportedAsOpaque_WithItsTrueLength()
|
|
{
|
|
await using var s = new MqttBrowseSession(MqttMode.Plain);
|
|
var binary = new byte[2048];
|
|
Array.Fill(binary, (byte)0xC0); // never legal UTF-8
|
|
s.Observe("t/bin", binary);
|
|
|
|
var a = (await s.AttributesAsync("t/bin", TestContext.Current.CancellationToken)).ShouldHaveSingleItem();
|
|
a.SecurityClass.ShouldBe("(2048 bytes, binary)"); // full length, not the clipped window
|
|
}
|
|
|
|
[Fact]
|
|
public async Task EmbeddedControlCharacters_AreScrubbedToSpaces()
|
|
{
|
|
// Trim() only strips the ends; an embedded newline would break the picker's single-line row.
|
|
await using var s = new MqttBrowseSession(MqttMode.Plain);
|
|
s.ObserveTopicForTest("t/multiline", "line one\nline two\ttabbed[31m");
|
|
|
|
var a = (await s.AttributesAsync("t/multiline", TestContext.Current.CancellationToken)).ShouldHaveSingleItem();
|
|
a.SecurityClass.ShouldNotContain("\n");
|
|
a.SecurityClass.ShouldNotContain("\t");
|
|
a.SecurityClass.ShouldNotContain("");
|
|
a.SecurityClass.ShouldStartWith("line one line two tabbed");
|
|
}
|
|
|
|
// ---------------------------------------------------------------- the browser
|
|
|
|
[Fact]
|
|
public void Ctor_IsConnectionFree_AndReportsTheMqttDriverType()
|
|
{
|
|
// The universal-browser CanBrowse pattern constructs a throwaway instance purely to ask
|
|
// what driver type it handles — the ctor must never touch the network.
|
|
var browser = new MqttDriverBrowser();
|
|
browser.DriverType.ShouldBe("Mqtt");
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(null, "#")]
|
|
[InlineData("", "#")]
|
|
[InlineData(" ", "#")]
|
|
[InlineData("factory", "factory/#")]
|
|
[InlineData("factory/", "factory/#")]
|
|
[InlineData("factory/line3", "factory/line3/#")]
|
|
public void BuildRootFilter_ScopesTheWildcardToTheConfiguredPrefix(string? prefix, string expected)
|
|
{
|
|
var opts = new MqttDriverOptions
|
|
{
|
|
Mode = MqttMode.Plain,
|
|
Plain = prefix is null ? null : new MqttPlainOptions { TopicPrefix = prefix },
|
|
};
|
|
MqttDriverBrowser.BuildRootFilter(opts).ShouldBe(expected);
|
|
}
|
|
|
|
[Fact]
|
|
public void BrowseClientIdSuffix_IsUnique_AndIsAppliedToTheConnectClientId()
|
|
{
|
|
// A broker disconnects an existing client when a new one CONNECTs with the same id —
|
|
// browsing must never knock the running driver offline.
|
|
var a = MqttDriverBrowser.BuildBrowseClientIdSuffix();
|
|
var b = MqttDriverBrowser.BuildBrowseClientIdSuffix();
|
|
a.ShouldNotBe(b);
|
|
a.ShouldStartWith("-browse-");
|
|
a.Length.ShouldBe("-browse-".Length + 8);
|
|
|
|
var opts = new MqttDriverOptions { ClientId = "otopcua-node1" };
|
|
MqttConnection.BuildClientOptions(opts, a).ClientId.ShouldBe("otopcua-node1" + a);
|
|
}
|
|
|
|
[Fact]
|
|
public void BrowseConnect_ForcesACleanSession()
|
|
{
|
|
// A transient browse identity must not leave queued-message state behind on the broker.
|
|
var opts = new MqttDriverOptions { CleanSession = false };
|
|
MqttDriverBrowser.ToBrowseOptions(opts).CleanSession.ShouldBeTrue();
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(1, 5)] // clamped up
|
|
[InlineData(15, 15)]
|
|
[InlineData(600, 30)] // clamped down
|
|
public void OpenBudget_IsClampedTo5To30Seconds(int configured, int expected)
|
|
{
|
|
var opts = new MqttDriverOptions { ConnectTimeoutSeconds = configured };
|
|
MqttDriverBrowser.OpenBudget(opts).ShouldBe(TimeSpan.FromSeconds(expected));
|
|
}
|
|
|
|
// ------------------------------------------------------- Sparkplug: the Last-Will landmine
|
|
|
|
[Fact]
|
|
public void MqttDriverOptions_CarryNothingWillShaped_SoABrowseSessionCanNeverFireAnNdeath()
|
|
{
|
|
// THE landmine (see the marked block on MqttDriverBrowser.ToBrowseOptions). A Sparkplug
|
|
// NDEATH is a Last Will: it is published by the BROKER, not by us, so it is invisible to
|
|
// PublishCountForTest — and any ungraceful end to a browse session would fire an NDEATH
|
|
// under the plant's real edge-node identity. ToBrowseOptions is where a will must be
|
|
// stripped; today there is nothing to strip, and this test is what makes that a checked
|
|
// fact rather than a claim: adding a will-shaped property anywhere in the options graph
|
|
// reddens it and forces the author to handle it in ToBrowseOptions.
|
|
static IEnumerable<string> Names(Type t) => t.GetProperties().Select(p => p.Name);
|
|
|
|
var suspects = Names(typeof(MqttDriverOptions))
|
|
.Concat(Names(typeof(MqttSparkplugOptions)))
|
|
.Concat(Names(typeof(MqttPlainOptions)))
|
|
.Where(n => n.Contains("will", StringComparison.OrdinalIgnoreCase)
|
|
|| n.Contains("testament", StringComparison.OrdinalIgnoreCase)
|
|
|| n.Contains("death", StringComparison.OrdinalIgnoreCase))
|
|
.ToList();
|
|
|
|
suspects.ShouldBeEmpty();
|
|
}
|
|
|
|
// ------------------------------------------------------- Sparkplug: birth-driven tree
|
|
|
|
[Fact]
|
|
public async Task SparkplugTree_FillsFromObservedBirths_BrowseNeverPublishes()
|
|
{
|
|
await using var s = new MqttBrowseSession(MqttMode.SparkplugB);
|
|
s.ObserveBirthForTest("Plant1", "EdgeA", "Filler1", "Temperature", SparkplugDataType.Float);
|
|
|
|
var groups = await s.RootAsync(TestContext.Current.CancellationToken);
|
|
groups.ShouldContain(n => n.DisplayName == "Plant1");
|
|
|
|
var nodes = await s.ExpandAsync("Plant1", TestContext.Current.CancellationToken);
|
|
nodes.ShouldContain(n => n.DisplayName == "EdgeA");
|
|
|
|
var devices = await s.ExpandAsync("Plant1/EdgeA", TestContext.Current.CancellationToken);
|
|
var device = devices.ShouldHaveSingleItem();
|
|
device.DisplayName.ShouldBe("Filler1");
|
|
device.Kind.ShouldBe(BrowseNodeKind.Folder);
|
|
|
|
var metrics = await s.ExpandAsync("Plant1/EdgeA/Filler1", TestContext.Current.CancellationToken);
|
|
var metric = metrics.ShouldHaveSingleItem();
|
|
metric.DisplayName.ShouldBe("Temperature");
|
|
metric.Kind.ShouldBe(BrowseNodeKind.Leaf);
|
|
metric.NodeId.ShouldBe("Plant1/EdgeA/Filler1::Temperature");
|
|
|
|
await s.AttributesAsync(metric.NodeId, TestContext.Current.CancellationToken);
|
|
|
|
s.PublishCountForTest.ShouldBe(0); // passive — browsing a live plant broker publishes nothing
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SparkplugTree_NodeLevelMetrics_HangDirectlyUnderTheEdgeNode()
|
|
{
|
|
// An NBIRTH metric has no device. It is NOT parked under a synthesised device folder —
|
|
// a fake segment would be indistinguishable from a real device of the same name, and the
|
|
// authored address tuple carries deviceId = null for exactly these.
|
|
await using var s = new MqttBrowseSession(MqttMode.SparkplugB);
|
|
s.ObserveBirthForTest("Plant1", "EdgeA", device: null, "Node Control/Rebirth", SparkplugDataType.Boolean);
|
|
s.ObserveBirthForTest("Plant1", "EdgeA", "Filler1", "Temperature", SparkplugDataType.Float);
|
|
|
|
var underEdge = await s.ExpandAsync("Plant1/EdgeA", TestContext.Current.CancellationToken);
|
|
underEdge.Select(n => n.DisplayName).ShouldBe(["Filler1", "Node Control/Rebirth"]);
|
|
|
|
var device = underEdge.Single(n => n.DisplayName == "Filler1");
|
|
device.Kind.ShouldBe(BrowseNodeKind.Folder);
|
|
|
|
// A metric name may itself contain '/'; it stays ONE leaf (the metric name is the tag's
|
|
// stable binding key, so a split would invite committing a partial name).
|
|
var nodeMetric = underEdge.Single(n => n.DisplayName == "Node Control/Rebirth");
|
|
nodeMetric.Kind.ShouldBe(BrowseNodeKind.Leaf);
|
|
nodeMetric.NodeId.ShouldBe("Plant1/EdgeA::Node Control/Rebirth");
|
|
(await s.ExpandAsync(nodeMetric.NodeId, TestContext.Current.CancellationToken)).ShouldBeEmpty();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SparkplugTree_ADeviceAndANodeMetricSharingALabel_AreBothKept()
|
|
{
|
|
// The two are siblings under the edge node and CAN share a label. They are distinct nodes with
|
|
// distinct ids, so neither may displace the other — a tree keyed by label would silently drop
|
|
// whichever arrived second, and the picker would offer a folder where a metric should be.
|
|
await using var s = new MqttBrowseSession(MqttMode.SparkplugB);
|
|
s.ObserveBirthForTest("Plant1", "EdgeA", "Temp", "Value", SparkplugDataType.Float); // device "Temp"
|
|
s.ObserveBirthForTest("Plant1", "EdgeA", null, "Temp", SparkplugDataType.Float); // metric "Temp"
|
|
|
|
var children = await s.ExpandAsync("Plant1/EdgeA", TestContext.Current.CancellationToken);
|
|
children.Count.ShouldBe(2);
|
|
children.ShouldContain(n => n.NodeId == "Plant1/EdgeA/Temp" && n.Kind == BrowseNodeKind.Folder);
|
|
children.ShouldContain(n => n.NodeId == "Plant1/EdgeA::Temp" && n.Kind == BrowseNodeKind.Leaf);
|
|
|
|
(await s.AttributesAsync("Plant1/EdgeA::Temp", TestContext.Current.CancellationToken))
|
|
.ShouldHaveSingleItem().Name.ShouldBe("Temp");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SparkplugTree_EmptyBirth_StillRendersItsDeviceAsAFolder()
|
|
{
|
|
// A DBIRTH carrying no metrics leaves a childless device node; it must not masquerade as a
|
|
// committable metric leaf.
|
|
await using var s = new MqttBrowseSession(MqttMode.SparkplugB);
|
|
s.ObserveEmptyBirthForTest("Plant1", "EdgeA", "Filler1");
|
|
|
|
var device = (await s.ExpandAsync("Plant1/EdgeA", TestContext.Current.CancellationToken))
|
|
.ShouldHaveSingleItem();
|
|
device.DisplayName.ShouldBe("Filler1");
|
|
device.Kind.ShouldBe(BrowseNodeKind.Folder);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SparkplugObserve_NonBirthMessages_BuildNoTree()
|
|
{
|
|
// Only NBIRTH/DBIRTH are self-describing. A DDATA metric carries an alias and no name at
|
|
// all, so anything built from one would be a tree of unnamed nodes.
|
|
await using var s = new MqttBrowseSession(MqttMode.SparkplugB);
|
|
s.ObserveRawForTest("spBv1.0/Plant1/DDATA/EdgeA/Filler1", [0x08, 0x01]);
|
|
s.ObserveRawForTest("spBv1.0/Plant1/NCMD/EdgeA", [0x08, 0x01]);
|
|
s.ObserveRawForTest("spBv1.0/Plant1/NDEATH/EdgeA", [0x08, 0x01]);
|
|
s.ObserveRawForTest("spBv1.0/STATE/host1", "ONLINE"u8.ToArray());
|
|
s.ObserveRawForTest("some/plain/topic", "23.5"u8.ToArray()); // not Sparkplug at all
|
|
|
|
(await s.RootAsync(TestContext.Current.CancellationToken)).ShouldBeEmpty();
|
|
s.PublishCountForTest.ShouldBe(0);
|
|
}
|
|
|
|
[Fact]
|
|
public void SparkplugObserve_GarbageBirthPayload_IsIgnoredAndNeverThrows()
|
|
{
|
|
// Runs on MQTTnet's dispatcher thread behind an unauthenticated firehose.
|
|
var s = new MqttBrowseSession(MqttMode.SparkplugB);
|
|
Should.NotThrow(() => s.ObserveRawForTest("spBv1.0/Plant1/NBIRTH/EdgeA", [0xFF, 0xFF, 0xFF, 0xFF]));
|
|
Should.NotThrow(() => s.ObserveRawForTest("spBv1.0/Plant1/NBIRTH/EdgeA", []));
|
|
}
|
|
|
|
// ------------------------------------------------------- Sparkplug: AttributesAsync
|
|
|
|
[Fact]
|
|
public async Task SparkplugAttributes_AreBirthDerived_NotPayloadSniffed()
|
|
{
|
|
await using var s = new MqttBrowseSession(MqttMode.SparkplugB);
|
|
s.ObserveBirthForTest("Plant1", "EdgeA", "Filler1", "Temperature", SparkplugDataType.Float);
|
|
|
|
var a = (await s.AttributesAsync(
|
|
"Plant1/EdgeA/Filler1::Temperature", TestContext.Current.CancellationToken)).ShouldHaveSingleItem();
|
|
|
|
a.Name.ShouldBe("Temperature");
|
|
a.DriverDataType.ShouldBe(nameof(Core.Abstractions.DriverDataType.Float32)); // from the birth
|
|
a.IsArray.ShouldBeFalse();
|
|
a.SecurityClass.ShouldContain("DBIRTH");
|
|
a.SecurityClass.ShouldContain("Float");
|
|
// The plain-mode payload-snippet machinery must not run: a Sparkplug body is protobuf, and
|
|
// sniffing it would report "(N bytes, binary)" for every metric in the plant.
|
|
a.SecurityClass.ShouldNotContain("binary");
|
|
a.SecurityClass.ShouldNotContain("bytes");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SparkplugAttributes_ArrayMetric_ReportsElementTypePlusIsArray()
|
|
{
|
|
await using var s = new MqttBrowseSession(MqttMode.SparkplugB);
|
|
s.ObserveBirthForTest("Plant1", "EdgeA", null, "Profile", SparkplugDataType.Int32Array);
|
|
|
|
var a = (await s.AttributesAsync(
|
|
"Plant1/EdgeA::Profile", TestContext.Current.CancellationToken)).ShouldHaveSingleItem();
|
|
a.DriverDataType.ShouldBe(nameof(Core.Abstractions.DriverDataType.Int32)); // element type
|
|
a.IsArray.ShouldBeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SparkplugAttributes_UnsupportedDataType_IsReportedNotDefaulted()
|
|
{
|
|
// ToDriverDataType() returns null for DataSet/Template/PropertySet/PropertySetList/Unknown.
|
|
// A null must never be coerced into a guessed DriverDataType — the operator has to see that
|
|
// the metric is not bindable.
|
|
await using var s = new MqttBrowseSession(MqttMode.SparkplugB);
|
|
s.ObserveBirthForTest("Plant1", "EdgeA", null, "Curve", SparkplugDataType.DataSet);
|
|
|
|
var a = (await s.AttributesAsync(
|
|
"Plant1/EdgeA::Curve", TestContext.Current.CancellationToken)).ShouldHaveSingleItem();
|
|
|
|
Enum.TryParse<Core.Abstractions.DriverDataType>(a.DriverDataType, ignoreCase: true, out _)
|
|
.ShouldBeFalse(); // not a guessed type — unmappable, and visibly so
|
|
a.SecurityClass.ShouldContain("unsupported");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SparkplugAttributes_StateTheBindingTuple_SoTheCommitNeverParsesTheNodeId()
|
|
{
|
|
// The address of a Sparkplug tag is (group, edgeNode, device?, metric) — NOT the node id. The
|
|
// session already holds the decomposition (it parsed the birth topic), so it states it; the
|
|
// AdminUI's browse-commit mapper reads these keys and never splits the id.
|
|
await using var s = new MqttBrowseSession(MqttMode.SparkplugB);
|
|
s.ObserveBirthForTest("OtOpcUaSim", "EdgeA", "Filler1", "Temperature", SparkplugDataType.Float);
|
|
|
|
var a = (await s.AttributesAsync(
|
|
"OtOpcUaSim/EdgeA/Filler1::Temperature", TestContext.Current.CancellationToken)).ShouldHaveSingleItem();
|
|
|
|
a.AddressFields.ShouldNotBeNull();
|
|
a.AddressFields![MqttTagConfigKeys.GroupId].ShouldBe("OtOpcUaSim");
|
|
a.AddressFields[MqttTagConfigKeys.EdgeNodeId].ShouldBe("EdgeA");
|
|
a.AddressFields[MqttTagConfigKeys.DeviceId].ShouldBe("Filler1");
|
|
a.AddressFields[MqttTagConfigKeys.MetricName].ShouldBe("Temperature");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SparkplugAttributes_NodeLevelMetric_StatesNoDeviceId()
|
|
{
|
|
// An NBIRTH metric belongs to the edge node itself. An invented/blank device id would be a
|
|
// device that does not exist — the same reason the tree hangs it directly under the edge node.
|
|
await using var s = new MqttBrowseSession(MqttMode.SparkplugB);
|
|
s.ObserveBirthForTest("OtOpcUaSim", "EdgeA", null, "Uptime", SparkplugDataType.Int64);
|
|
|
|
var a = (await s.AttributesAsync(
|
|
"OtOpcUaSim/EdgeA::Uptime", TestContext.Current.CancellationToken)).ShouldHaveSingleItem();
|
|
|
|
a.AddressFields.ShouldNotBeNull();
|
|
a.AddressFields!.ContainsKey(MqttTagConfigKeys.DeviceId).ShouldBeFalse();
|
|
a.AddressFields[MqttTagConfigKeys.EdgeNodeId].ShouldBe("EdgeA");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SparkplugAttributes_MetricNameContainingASlash_IsStatedWhole()
|
|
{
|
|
// 'Node Control/Rebirth' is a canonical Sparkplug metric name. Its node id is
|
|
// 'OtOpcUaSim/EdgeA::Node Control/Rebirth' — un-splittable back into the tuple, which is why the
|
|
// tuple travels rather than the id.
|
|
await using var s = new MqttBrowseSession(MqttMode.SparkplugB);
|
|
s.ObserveBirthForTest("OtOpcUaSim", "EdgeA", null, "Node Control/Rebirth", SparkplugDataType.Boolean);
|
|
|
|
var a = (await s.AttributesAsync(
|
|
"OtOpcUaSim/EdgeA::Node Control/Rebirth", TestContext.Current.CancellationToken)).ShouldHaveSingleItem();
|
|
|
|
a.AddressFields.ShouldNotBeNull();
|
|
a.AddressFields![MqttTagConfigKeys.MetricName].ShouldBe("Node Control/Rebirth");
|
|
a.AddressFields[MqttTagConfigKeys.EdgeNodeId].ShouldBe("EdgeA");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SparkplugAttributes_DeviceAndNodeLevelMetricsOfTheSameName_StateDifferentAddresses()
|
|
{
|
|
// The two are siblings sharing a label ('EdgeA/Temp' the device folder vs 'EdgeA::Temp' the
|
|
// node-level metric). Anything that re-derived the address from the label would collapse them.
|
|
await using var s = new MqttBrowseSession(MqttMode.SparkplugB);
|
|
s.ObserveBirthForTest("OtOpcUaSim", "EdgeA", null, "Temp", SparkplugDataType.Float);
|
|
s.ObserveBirthForTest("OtOpcUaSim", "EdgeA", "Filler1", "Temp", SparkplugDataType.Float);
|
|
|
|
var nodeLevel = (await s.AttributesAsync(
|
|
"OtOpcUaSim/EdgeA::Temp", TestContext.Current.CancellationToken)).ShouldHaveSingleItem();
|
|
var deviceLevel = (await s.AttributesAsync(
|
|
"OtOpcUaSim/EdgeA/Filler1::Temp", TestContext.Current.CancellationToken)).ShouldHaveSingleItem();
|
|
|
|
nodeLevel.AddressFields!.ContainsKey(MqttTagConfigKeys.DeviceId).ShouldBeFalse();
|
|
deviceLevel.AddressFields![MqttTagConfigKeys.DeviceId].ShouldBe("Filler1");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PlainAttributes_StateTheTopic_AsTheAddress()
|
|
{
|
|
await using var s = new MqttBrowseSession(MqttMode.Plain);
|
|
s.ObserveTopicForTest("otopcua/fixture/oven/temp", "21.5");
|
|
|
|
var a = (await s.AttributesAsync(
|
|
"otopcua/fixture/oven/temp", TestContext.Current.CancellationToken)).ShouldHaveSingleItem();
|
|
|
|
a.AddressFields.ShouldNotBeNull();
|
|
a.AddressFields![MqttTagConfigKeys.Topic].ShouldBe("otopcua/fixture/oven/temp");
|
|
a.AddressFields.ContainsKey(MqttTagConfigKeys.MetricName).ShouldBeFalse(); // Plain has no metric
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(MqttMode.SparkplugB, true)]
|
|
[InlineData(MqttMode.Plain, false)]
|
|
public void RebirthAvailable_IsSparkplugOnly(MqttMode mode, bool expected)
|
|
{
|
|
// The picker draws its Request-rebirth affordance off this. A plain window has no re-announce
|
|
// action at all — RequestRebirthAsync refuses one — so offering the button would be a lie.
|
|
new MqttBrowseSession(mode).RebirthAvailable.ShouldBe(expected);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SparkplugAttributes_ForAFolderNode_AreEmpty()
|
|
{
|
|
await using var s = new MqttBrowseSession(MqttMode.SparkplugB);
|
|
s.ObserveBirthForTest("Plant1", "EdgeA", "Filler1", "Temperature", SparkplugDataType.Float);
|
|
|
|
(await s.AttributesAsync("Plant1", TestContext.Current.CancellationToken)).ShouldBeEmpty();
|
|
(await s.AttributesAsync("Plant1/EdgeA", TestContext.Current.CancellationToken)).ShouldBeEmpty();
|
|
(await s.AttributesAsync("Plant1/EdgeA/Filler1", TestContext.Current.CancellationToken)).ShouldBeEmpty();
|
|
(await s.AttributesAsync("no/such/node", TestContext.Current.CancellationToken)).ShouldBeEmpty();
|
|
}
|
|
|
|
// ------------------------------------------------------- Sparkplug: the one sanctioned publish
|
|
|
|
[Fact]
|
|
public async Task RequestRebirthAsync_ScopedToNode_PublishesOneNcmd()
|
|
{
|
|
await using var s = new MqttBrowseSession(MqttMode.SparkplugB);
|
|
s.ObserveBirthForTest("Plant1", "EdgeA", "Filler1", "T", SparkplugDataType.Float);
|
|
|
|
var published = await s.RequestRebirthAsync("Plant1/EdgeA", TestContext.Current.CancellationToken);
|
|
|
|
published.ShouldBe(1);
|
|
s.PublishCountForTest.ShouldBe(1);
|
|
s.PublishedTopicsForTest.ShouldBe(["spBv1.0/Plant1/NCMD/EdgeA"]);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RequestRebirthAsync_ForANeverSeenEdgeNode_StillPublishes()
|
|
{
|
|
// A node that has never birthed is the PRIME rebirth target — refusing to address one
|
|
// because the observation window never saw it would defeat the whole feature.
|
|
await using var s = new MqttBrowseSession(MqttMode.SparkplugB);
|
|
|
|
(await s.RequestRebirthAsync("Plant1/EdgeGhost", TestContext.Current.CancellationToken)).ShouldBe(1);
|
|
s.PublishedTopicsForTest.ShouldBe(["spBv1.0/Plant1/NCMD/EdgeGhost"]);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RequestRebirthAsync_FromADeviceOrMetricNodeId_ResolvesUpToItsEdgeNode()
|
|
{
|
|
// NCMD is node-addressed; there is no device-scoped rebirth. Selecting a metric and
|
|
// clicking Request-rebirth must address the metric's OWNING edge node — resolved from the
|
|
// tuple stored on the node, never re-parsed out of the id string.
|
|
await using var s = new MqttBrowseSession(MqttMode.SparkplugB);
|
|
s.ObserveBirthForTest("Plant1", "EdgeA", "Filler1", "T", SparkplugDataType.Float);
|
|
|
|
(await s.RequestRebirthAsync("Plant1/EdgeA/Filler1", TestContext.Current.CancellationToken)).ShouldBe(1);
|
|
(await s.RequestRebirthAsync("Plant1/EdgeA/Filler1::T", TestContext.Current.CancellationToken)).ShouldBe(1);
|
|
|
|
s.PublishedTopicsForTest.ShouldBe(["spBv1.0/Plant1/NCMD/EdgeA", "spBv1.0/Plant1/NCMD/EdgeA"]);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RequestRebirthAsync_GroupScope_PublishesExactlyOneNcmdPerObservedEdgeNode()
|
|
{
|
|
await using var s = new MqttBrowseSession(MqttMode.SparkplugB);
|
|
s.ObserveBirthForTest("Plant1", "EdgeB", null, "T", SparkplugDataType.Float);
|
|
s.ObserveBirthForTest("Plant1", "EdgeA", "Filler1", "T", SparkplugDataType.Float);
|
|
s.ObserveBirthForTest("Plant1", "EdgeA", "Filler2", "T", SparkplugDataType.Float); // same node
|
|
s.ObserveBirthForTest("Plant2", "EdgeZ", null, "T", SparkplugDataType.Float); // other group
|
|
|
|
var published = await s.RequestRebirthAsync("Plant1", TestContext.Current.CancellationToken);
|
|
|
|
published.ShouldBe(2);
|
|
s.PublishCountForTest.ShouldBe(2);
|
|
s.PublishedTopicsForTest.ShouldBe(["spBv1.0/Plant1/NCMD/EdgeA", "spBv1.0/Plant1/NCMD/EdgeB"]);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RequestRebirthAsync_GroupScope_BeyondTheCap_PublishesNothingAtAll()
|
|
{
|
|
// A 200-node group must not emit 200 NCMDs. The refusal is all-or-nothing: a partial fan-out
|
|
// would leave the operator unable to say which half of the plant was asked to rebirth.
|
|
await using var s = new MqttBrowseSession(MqttMode.SparkplugB);
|
|
for (var i = 0; i <= MqttBrowseSession.MaxGroupRebirthNodes; i++)
|
|
s.ObserveBirthForTest("Plant1", $"Edge{i:D3}", null, "T", SparkplugDataType.Float);
|
|
|
|
var ex = await Should.ThrowAsync<InvalidOperationException>(
|
|
async () => await s.RequestRebirthAsync("Plant1", TestContext.Current.CancellationToken));
|
|
|
|
ex.Message.ShouldContain(MqttBrowseSession.MaxGroupRebirthNodes.ToString());
|
|
s.PublishCountForTest.ShouldBe(0);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RequestRebirthAsync_GroupScope_TruncatedBeforeAnyEdgeNode_PublishesNothing()
|
|
{
|
|
// The node cap can leave a group node with no edge node recorded beneath it. A group that
|
|
// resolves to zero targets is refused rather than quietly succeeding with a count of 0 — the
|
|
// operator clicked Request-rebirth and is entitled to know nothing was sent.
|
|
await using var s = new MqttBrowseSession(MqttMode.SparkplugB, nodeCap: 1);
|
|
s.ObserveBirthForTest("Plant1", "EdgeA", null, "T", SparkplugDataType.Float);
|
|
|
|
s.Truncated.ShouldBeTrue();
|
|
(await s.ExpandAsync("Plant1", TestContext.Current.CancellationToken)).ShouldBeEmpty();
|
|
|
|
await Should.ThrowAsync<InvalidOperationException>(
|
|
async () => await s.RequestRebirthAsync("Plant1", TestContext.Current.CancellationToken));
|
|
s.PublishCountForTest.ShouldBe(0);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("")]
|
|
[InlineData(" ")]
|
|
[InlineData("Plant1")] // a group this window has never observed
|
|
[InlineData("Plant1/EdgeA/Filler1/Extra")] // unknown, and not resolvable to an edge node
|
|
public async Task RequestRebirthAsync_UnusableScope_PublishesNothing(string scope)
|
|
{
|
|
await using var s = new MqttBrowseSession(MqttMode.SparkplugB);
|
|
|
|
await Should.ThrowAsync<ArgumentException>(
|
|
async () => await s.RequestRebirthAsync(scope, TestContext.Current.CancellationToken));
|
|
s.PublishCountForTest.ShouldBe(0);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RequestRebirthAsync_InPlainMode_IsRefused()
|
|
{
|
|
// P1's guarantee is unconditional: a plain-topic browse window publishes nothing, ever.
|
|
await using var s = new MqttBrowseSession(MqttMode.Plain);
|
|
s.ObserveTopicForTest("factory/oven/temp", "1");
|
|
|
|
await Should.ThrowAsync<NotSupportedException>(
|
|
async () => await s.RequestRebirthAsync("Plant1/EdgeA", TestContext.Current.CancellationToken));
|
|
s.PublishCountForTest.ShouldBe(0);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RequestRebirthAsync_AfterDispose_Throws()
|
|
{
|
|
var s = new MqttBrowseSession(MqttMode.SparkplugB);
|
|
await s.DisposeAsync();
|
|
|
|
await Should.ThrowAsync<ObjectDisposedException>(
|
|
async () => await s.RequestRebirthAsync("Plant1/EdgeA", TestContext.Current.CancellationToken));
|
|
s.PublishCountForTest.ShouldBe(0);
|
|
}
|
|
|
|
// ------------------------------------------------------- Sparkplug: the browser
|
|
|
|
[Fact]
|
|
public async Task OpenAsync_SparkplugMode_NoLongerRefuses_ButStillReachesTheBroker()
|
|
{
|
|
// Task 23 unseals the Sparkplug branch. The open still has to CONNECT, so an unreachable
|
|
// broker fails the way any other open does — what must NOT come back is the Task-10
|
|
// NotSupportedException seal.
|
|
var browser = new MqttDriverBrowser();
|
|
const string cfg = """
|
|
{"host":"broker.invalid","port":8883,"mode":"SparkplugB","connectTimeoutSeconds":5,
|
|
"sparkplug":{"groupId":"Plant1"}}
|
|
""";
|
|
|
|
var ex = await Should.ThrowAsync<Exception>(
|
|
async () => await browser.OpenAsync(cfg, TestContext.Current.CancellationToken));
|
|
ex.ShouldNotBeOfType<NotSupportedException>();
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("Plant1", "spBv1.0/Plant1/#")]
|
|
[InlineData("", "spBv1.0/#")] // group not authored yet — discover every group
|
|
[InlineData(" ", "spBv1.0/#")]
|
|
public void BuildRootFilter_SparkplugMode_ScopesToTheSparkplugNamespace(string groupId, string expected)
|
|
{
|
|
var opts = new MqttDriverOptions
|
|
{
|
|
Mode = MqttMode.SparkplugB,
|
|
Sparkplug = new MqttSparkplugOptions { GroupId = groupId },
|
|
Plain = new MqttPlainOptions { TopicPrefix = "ignored/in/sparkplug/mode" },
|
|
};
|
|
MqttDriverBrowser.BuildRootFilter(opts).ShouldBe(expected);
|
|
}
|
|
|
|
[Fact]
|
|
public void BuildRootFilter_SparkplugMode_WithNoSparkplugSection_DiscoversEveryGroup()
|
|
{
|
|
var opts = new MqttDriverOptions { Mode = MqttMode.SparkplugB, Sparkplug = null };
|
|
MqttDriverBrowser.BuildRootFilter(opts).ShouldBe("spBv1.0/#");
|
|
}
|
|
}
|