c74ccc55b5
`SparkplugCodec` handled scalars; `DataSet`, `Template` and `PropertySet` were skipped
with a warning. They now flatten to addressable tags (owner decision, recorded on the
issue):
Template → sibling metrics, `/`-joined, recursively: Motor → Motor/Speed, Motor/Stator/Temp
DataSet → one metric per cell, {column}/{rowIndex}: Recipe → Recipe/Step/0, Recipe/Target/1
PropertySet → node metadata, never tags — it describes a metric rather than being one
Flattening was chosen over OPC UA Structures because flattened scalars are consumable by
any client with no type dictionary, and the result mirrors how the `/raw` tree works.
**The row index is always present, including on row 0.** Emitting a bare column name for
a single-row table and an indexed one otherwise would make a tag's name depend on how many
rows the plant published — a table growing from one row to two would rename every tag
already authored against it.
**A DATA metric carries the parent's alias and no name**, so member names cannot be
composed until the alias table has resolved the parent. Expansion therefore happens in
`SparkplugIngestor.PublishStructuredMembers` — after resolution, at the single funnel both
the DATA and birth-value paths already pass through — not at decode time. The alias table
installs a binding per member so browse enumerates them and a DDATA member that omits its
datatype falls back to the birth's (guessing from the CLR type would read a signed Int32 of
-42 out of its unsigned field as 4294967254).
**Skip-and-warn survives, and refusal is whole** — the explicit requirement on the issue.
A template definition, an unnamed member, an extension value, a column/type mismatch, a
short row, or a payload past MaxDepth(5)/MaxMembers(512) refuses the ENTIRE metric. A
silently half-flattened UDT looks exactly like a complete one in the picker.
The browse picker offers the members and deliberately NOT the structured parent, which has
no value and no servable datatype. The rediscovery signature likewise tracks members rather
than the parent, or a UDT gaining a member would leave the signature unmoved.
Member paths contain `/`, which #509 already solved: the slashed name travels intact as
`metricName` while the derived tag Name is coerced to a legal RawPath segment.
Live-gated 3/3 against the Mosquitto fixture with the project-owned simulator extended to
publish a nested Template and a 2x2 DataSet. Falsified at three points: dropping the member
bindings, not expanding the parent, and refusing to flatten templates each turn exactly the
expected tests red.
Also fixes a regression from the #511 commit in the same branch: `AddOtOpcUaDriverFactories`
resolved `IDriverPrimaryRoleSource` with `GetRequiredService` but only `AddOtOpcUaRuntime`
registered it, so a graph wiring driver factories without the full runtime failed at
resolution. Two Host integration suites do exactly that and caught it; both extensions now
TryAdd the same concrete `RedundancyRoleView`, so the registration is order-independent and
the fail-loud resolve is kept.
Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
921 lines
43 KiB
C#
921 lines
43 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" };
|
|
MqttClientOptionsFactory.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_TemplateMetric_OffersItsMembersAndNotItself()
|
|
{
|
|
// Gitea #515. The parent has no value and no servable datatype, so a leaf for `Motor` would
|
|
// offer the operator an address that can only ever commit a tag which never reads — and the
|
|
// picker gives no hint that one leaf is real and the other is not.
|
|
await using var s = new MqttBrowseSession(MqttMode.SparkplugB);
|
|
s.ObserveTemplateBirthForTest(
|
|
"Plant1", "EdgeA", "Filler1", "Motor",
|
|
("Speed", SparkplugDataType.Double),
|
|
("Torque", SparkplugDataType.Double));
|
|
|
|
var metrics = await s.ExpandAsync("Plant1/EdgeA/Filler1", TestContext.Current.CancellationToken);
|
|
|
|
metrics.Select(n => n.DisplayName).ShouldBe(["Motor/Speed", "Motor/Torque"]);
|
|
metrics.ShouldNotContain(n => n.DisplayName == "Motor");
|
|
metrics[0].Kind.ShouldBe(BrowseNodeKind.Leaf);
|
|
|
|
// The metric name travels INTACT as the node id — it is the binding key. Only the derived tag
|
|
// Name is later coerced to a legal RawPath segment, by RawBrowseCommitMapper (Gitea #509).
|
|
metrics[0].NodeId.ShouldBe("Plant1/EdgeA/Filler1::Motor/Speed");
|
|
|
|
s.PublishCountForTest.ShouldBe(0);
|
|
}
|
|
|
|
[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/#");
|
|
}
|
|
}
|