using System.Text;
using Google.Protobuf;
using Org.Eclipse.Tahu.Protobuf;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Sparkplug;
using TahuDataType = Org.Eclipse.Tahu.Protobuf.DataType;
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests;
///
/// Shell-level tests for : authored-only discovery, the lifecycle
/// surface (Reinitialize / Shutdown / health / footprint / cache flush), and the
/// IReadable batch contract. Nothing here dials a broker — every test exercises the
/// connection-free half of the driver, which is exactly the half a chatty broker must not be
/// able to influence.
///
[Trait("Category", "Unit")]
public sealed class MqttDriverDiscoveryTests
{
///
/// An authored TagConfig blob. dataType uses the real
/// member names — there is no Double; the 64-bit float is Float64.
///
private static string TagJson(string topic, string dataType = "String", string payloadFormat = "Raw")
=> $$"""{"topic":"{{topic}}","payloadFormat":"{{payloadFormat}}","dataType":"{{dataType}}"}""";
private static RawTagEntry Tag(string rawPath, string topic, string dataType = "String")
=> new(rawPath, TagJson(topic, dataType), WriteIdempotent: false);
private static MqttDriver PlainDriver(params RawTagEntry[] tags)
=> new(new MqttDriverOptions { Mode = MqttMode.Plain, RawTags = tags }, "d", null);
///
/// Options aimed at a definitely-closed port so a connect is refused immediately rather
/// than hanging — the tests that must prove a rebuild actually dials need the dial to fail
/// fast, not to burn a connect deadline.
///
private static MqttDriverOptions ClosedPortOptions(params RawTagEntry[] tags) => new()
{
Mode = MqttMode.Plain,
Host = "127.0.0.1",
Port = 1,
UseTls = false,
ConnectTimeoutSeconds = 2,
RawTags = tags,
};
private static MqttDriver ClosedPortDriver(params RawTagEntry[] tags)
=> new(ClosedPortOptions(tags), "d", null);
///
/// Serializes a full options record as the reinitialize blob. Round-tripping the whole record
/// (rather than hand-writing a partial JSON object) guarantees that only the field the caller
/// changed differs — a hand-written delta silently omitting a session field would take the
/// rebuild branch for the wrong reason and the test would pass vacuously.
///
private static string DeltaJson(MqttDriverOptions options)
=> System.Text.Json.JsonSerializer.Serialize(options, MqttJson.Options);
///
/// Plain mode replays ONLY the authored tag set: one variable per authored raw tag, a
/// single discovery pass (), and no online
/// discovery — the universal browser must never treat a broker's topic traffic as a
/// browsable address space.
///
[Fact]
public async Task DiscoverAsync_Plain_StreamsAuthoredTagsOnly_PolicyOnce()
{
var driver = PlainDriver(Tag("Plant/Mqtt/dev1/Temp", "f/t"));
var b = new CapturingAddressSpaceBuilder();
await driver.DiscoverAsync(b, CancellationToken.None);
b.Variables.Count.ShouldBe(1);
b.Variables[0].Info.FullName.ShouldBe("Plant/Mqtt/dev1/Temp");
driver.RediscoverPolicy.ShouldBe(DiscoveryRediscoverPolicy.Once);
((ITagDiscovery)driver).SupportsOnlineDiscovery.ShouldBeFalse();
}
///
/// The falsifiability pin for authored-only discovery: a message that arrives on a topic no
/// authored tag names must not add anything to the discovered set. A driver that
/// auto-provisioned from broker traffic would grow the address space on every deploy against
/// a chatty broker.
///
[Fact]
public async Task DiscoverAsync_UnauthoredBrokerTraffic_DoesNotAppear()
{
var driver = PlainDriver(Tag("Plant/Mqtt/dev1/Temp", "f/t"));
// Traffic this driver never authored — a neighbouring publisher on the same broker.
driver.Subscriptions.HandleMessage("some/other/topic", Encoding.UTF8.GetBytes("42"), retained: false);
driver.Subscriptions.HandleMessage("f/t/deeper", Encoding.UTF8.GetBytes("42"), retained: false);
var b = new CapturingAddressSpaceBuilder();
await driver.DiscoverAsync(b, CancellationToken.None);
b.Variables.Select(v => v.Info.FullName).ShouldBe(["Plant/Mqtt/dev1/Temp"]);
}
/// A raw tag whose TagConfig does not map is skipped, never thrown, and never discovered.
[Fact]
public async Task DiscoverAsync_SkipsUnmappableTagConfig()
{
var driver = PlainDriver(
Tag("Plant/Mqtt/dev1/Good", "f/t"),
new RawTagEntry("Plant/Mqtt/dev1/Bad", "not-json", WriteIdempotent: false));
var b = new CapturingAddressSpaceBuilder();
await driver.DiscoverAsync(b, CancellationToken.None);
b.Variables.Select(v => v.Info.FullName).ShouldBe(["Plant/Mqtt/dev1/Good"]);
}
/// Discovered MQTT variables are read-only — this driver has no IWritable leg.
[Fact]
public async Task DiscoverAsync_MarksVariablesViewOnly()
{
var driver = PlainDriver(Tag("Plant/Mqtt/dev1/Temp", "f/t", "Float64"));
var b = new CapturingAddressSpaceBuilder();
await driver.DiscoverAsync(b, CancellationToken.None);
b.Variables[0].Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly);
b.Variables[0].Info.DriverDataType.ShouldBe(DriverDataType.Float64);
}
///
/// The falsifiability pin for the batch-read contract: a reference that is not an authored
/// tag degrades to BadWaitingForInitialData in its own slot rather than throwing and
/// failing every other reference in the same batch.
///
[Fact]
public async Task ReadAsync_UnknownReference_DoesNotThrow_AndDegradesPerRef()
{
var driver = PlainDriver(Tag("Plant/Mqtt/dev1/Temp", "f/t"));
driver.Subscriptions.HandleMessage("f/t", Encoding.UTF8.GetBytes("hot"), retained: false);
var results = await driver.ReadAsync(
["Plant/Mqtt/dev1/Temp", "Plant/Mqtt/dev1/NeverAuthored"],
CancellationToken.None);
results.Count.ShouldBe(2);
results[0].StatusCode.ShouldBe(0u);
results[0].Value.ShouldBe("hot");
results[1].StatusCode.ShouldBe(0x80320000u); // BadWaitingForInitialData
}
/// An empty batch is legal and returns an empty result, not an exception.
[Fact]
public async Task ReadAsync_EmptyBatch_ReturnsEmpty()
{
var driver = PlainDriver(Tag("Plant/Mqtt/dev1/Temp", "f/t"));
var results = await driver.ReadAsync([], CancellationToken.None);
results.ShouldBeEmpty();
}
///
/// The falsifiability pin for the cache-flush contract: the last-value cache backs
/// IReadable, so flushing it would silently turn every subscribed node Bad under
/// memory pressure. FlushOptionalCachesAsync must leave it untouched.
///
[Fact]
public async Task FlushOptionalCachesAsync_LeavesLastValueCacheIntact()
{
var driver = PlainDriver(Tag("Plant/Mqtt/dev1/Temp", "f/t"));
driver.Subscriptions.HandleMessage("f/t", Encoding.UTF8.GetBytes("hot"), retained: false);
await driver.FlushOptionalCachesAsync(CancellationToken.None);
var results = await driver.ReadAsync(["Plant/Mqtt/dev1/Temp"], CancellationToken.None);
results[0].StatusCode.ShouldBe(0u);
results[0].Value.ShouldBe("hot");
}
/// A malformed reinitialize delta must never fault the driver, and must not lose the running config.
[Fact]
public async Task ReinitializeAsync_MalformedDelta_KeepsRunningConfig_AndDoesNotFault()
{
var driver = PlainDriver(Tag("Plant/Mqtt/dev1/Temp", "f/t"));
await Should.NotThrowAsync(() => driver.ReinitializeAsync("{ this is not json", CancellationToken.None));
driver.GetHealth().State.ShouldNotBe(DriverState.Faulted);
var b = new CapturingAddressSpaceBuilder();
await driver.DiscoverAsync(b, CancellationToken.None);
b.Variables.Select(v => v.Info.FullName).ShouldBe(["Plant/Mqtt/dev1/Temp"]);
}
///
/// A delta that changes only the authored tag set is applied in place — no teardown, no
/// reconnect — and is applied WHOLESALE: a tag the redeploy dropped stops being discovered.
///
[Fact]
public async Task ReinitializeAsync_TagOnlyDelta_AppliedInPlace_Wholesale()
{
var driver = PlainDriver(Tag("Plant/Mqtt/dev1/Temp", "f/t"));
var pressure = System.Text.Json.JsonSerializer.Serialize(TagJson("f/p"));
var flow = System.Text.Json.JsonSerializer.Serialize(TagJson("f/q"));
var delta = $$"""
{
"mode": "Plain",
"rawTags": [
{ "rawPath": "Plant/Mqtt/dev1/Pressure", "tagConfig": {{pressure}}, "writeIdempotent": false },
{ "rawPath": "Plant/Mqtt/dev1/Flow", "tagConfig": {{flow}}, "writeIdempotent": false }
]
}
""";
await driver.ReinitializeAsync(delta, CancellationToken.None);
var b = new CapturingAddressSpaceBuilder();
await driver.DiscoverAsync(b, CancellationToken.None);
b.Variables.Select(v => v.Info.FullName)
.OrderBy(x => x, StringComparer.Ordinal)
.ShouldBe(["Plant/Mqtt/dev1/Flow", "Plant/Mqtt/dev1/Pressure"]);
}
///
/// A delta that changes the broker endpoint takes the rebuild branch: it must actually
/// dial the new endpoint (proven here by the refused connect), and a rebuild whose connect
/// fails must land rather than letting the failure escape
/// with the health surface still claiming Healthy.
///
[Fact]
public async Task ReinitializeAsync_SessionChangingDelta_Rebuilds_AndFaultsOnUnreachableBroker()
{
var driver = ClosedPortDriver(Tag("Plant/Mqtt/dev1/Temp", "f/t"));
// Only Host differs from the ctor options — everything else round-trips identically.
var delta = DeltaJson(ClosedPortOptions() with { Host = "127.0.0.2" });
await Should.ThrowAsync(() => driver.ReinitializeAsync(delta, CancellationToken.None));
driver.GetHealth().State.ShouldBe(DriverState.Faulted);
}
///
/// Pins that IngestIdentity is nested inside SessionIdentity: a delta touching
/// ONLY an ingest setting still rebuilds. Without the nesting it would be judged "same
/// session", applied in place, and the subscription manager that actually reads
/// MaxPayloadBytes would never be rebuilt — a config change that silently does nothing.
///
[Fact]
public async Task ReinitializeAsync_IngestOnlyDelta_AlsoRebuilds()
{
var driver = ClosedPortDriver(Tag("Plant/Mqtt/dev1/Temp", "f/t"));
var delta = DeltaJson(ClosedPortOptions() with { MaxPayloadBytes = 4096 });
// Reaching the (refused) connect at all is the proof the rebuild branch was taken; the
// tag-only test above is the control that shows an in-place delta never dials.
await Should.ThrowAsync(() => driver.ReinitializeAsync(delta, CancellationToken.None));
}
///
/// The falsifiability pin for the orphaned-connection class:
/// must serialize behind an in-flight lifecycle operation, not race past it.
///
///
/// An ungated dispose running while a rebuild holds the gate observes _connection == null
/// (the rebuild has torn the old session down but not yet assigned the new one), does nothing,
/// and sets the disposed flag — after which the rebuild assigns a live connection plus a
/// host-probe loop that no later dispose can reach. Here the driver is parked inside
/// InitializeCoreAsync at the pre-connect hook; the assertion is that dispose does not
/// complete while it is parked, and does complete once it is released.
///
[Fact]
public async Task DisposeAsync_SerializesBehindAnInFlightLifecycleOperation()
{
var driver = ClosedPortDriver(Tag("Plant/Mqtt/dev1/Temp", "f/t"));
var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
driver.BeforeConnectHookForTests = async _ =>
{
entered.TrySetResult();
await release.Task;
};
var initialize = Task.Run(() => driver.InitializeAsync("", CancellationToken.None));
await entered.Task.WaitAsync(TimeSpan.FromSeconds(5));
var dispose = driver.DisposeAsync().AsTask();
// The gate is held by the parked initialize. Dispose must still be waiting. (The gate wait is
// bounded by ConnectTimeoutSeconds = 2 s, so this 300 ms window is comfortably inside it —
// an ungated dispose returns essentially instantly.)
var raced = await Task.WhenAny(dispose, Task.Delay(TimeSpan.FromMilliseconds(300)));
raced.ShouldNotBe(dispose, "DisposeAsync returned while a lifecycle operation held the gate");
release.SetResult();
await Should.ThrowAsync(() => initialize); // connect refused on the closed port
await dispose.WaitAsync(TimeSpan.FromSeconds(10));
dispose.IsCompletedSuccessfully.ShouldBeTrue();
}
/// Identity is fixed at construction and must match the persisted DriverInstance.DriverType.
[Fact]
public void Identity_IsMqtt()
{
var driver = PlainDriver();
driver.DriverType.ShouldBe("Mqtt");
driver.DriverInstanceId.ShouldBe("d");
}
/// A driver that has never been initialized reports Unknown, not Healthy.
[Fact]
public void GetHealth_BeforeInitialize_IsUnknown()
{
PlainDriver().GetHealth().State.ShouldBe(DriverState.Unknown);
}
/// Plain mode never signals rediscovery — the authored set only changes by redeploy.
[Fact]
public async Task Plain_NeverRaisesRediscoveryNeeded()
{
var driver = PlainDriver(Tag("Plant/Mqtt/dev1/Temp", "f/t"));
var fired = 0;
((IRediscoverable)driver).OnRediscoveryNeeded += (_, _) => Interlocked.Increment(ref fired);
driver.Subscriptions.HandleMessage("f/t", Encoding.UTF8.GetBytes("hot"), retained: false);
await driver.DiscoverAsync(new CapturingAddressSpaceBuilder(), CancellationToken.None);
fired.ShouldBe(0);
}
/// The footprint tracks the authored table; an empty driver reports no driver-attributable bytes.
[Fact]
public void GetMemoryFootprint_TracksAuthoredTable()
{
PlainDriver().GetMemoryFootprint().ShouldBe(0);
PlainDriver(Tag("Plant/Mqtt/dev1/Temp", "f/t")).GetMemoryFootprint().ShouldBeGreaterThan(0);
}
/// Shutdown on a never-initialized driver is a no-op, not a null-reference.
[Fact]
public async Task ShutdownAsync_BeforeInitialize_DoesNotThrow()
{
var driver = PlainDriver(Tag("Plant/Mqtt/dev1/Temp", "f/t"));
await Should.NotThrowAsync(() => driver.ShutdownAsync(CancellationToken.None));
driver.GetHealth().State.ShouldBe(DriverState.Unknown);
}
/// The single-broker connectivity probe names the configured endpoint.
[Fact]
public void GetHostStatuses_ReportsTheConfiguredBroker()
{
var driver = new MqttDriver(
new MqttDriverOptions { Mode = MqttMode.Plain, Host = "broker.example", Port = 1883 },
"d",
null);
var statuses = ((IHostConnectivityProbe)driver).GetHostStatuses();
statuses.Count.ShouldBe(1);
statuses[0].HostName.ShouldBe("broker.example:1883");
statuses[0].State.ShouldBe(HostState.Unknown);
}
// =================================================================================
// Task 22 — Sparkplug discovery policy, birth-filled datatypes, rediscovery trigger
// =================================================================================
///
/// Sparkplug's discovered shape is not fully known at connect: a tag authored without a
/// dataType takes its type from the birth certificate, which arrives asynchronously.
/// That is precisely the contract — the
/// host re-runs discovery until the captured set settles.
///
[Fact]
public void SparkplugMode_RediscoverPolicy_IsUntilStable()
=> SparkplugDriver().RediscoverPolicy.ShouldBe(DiscoveryRediscoverPolicy.UntilStable);
///
/// The mode gate, asserted from the other side: plain MQTT's authored set is fully known
/// synchronously, so it must stay . A blanket flip
/// to UntilStable would make every plain deployment re-run discovery on a timer for a
/// tree that can never change.
///
[Fact]
public void PlainMode_RediscoverPolicy_StaysOnce()
=> PlainDriver(Tag("Plant/Mqtt/dev1/Temp", "f/t")).RediscoverPolicy.ShouldBe(DiscoveryRediscoverPolicy.Once);
///
/// A DBIRTH for an authored device the driver has never held a birth for introduces the
/// metrics' datatypes, so the discovered tree genuinely changed: rediscovery fires.
///
[Fact]
public void NewDbirth_FiresOnRediscoveryNeeded()
{
var driver = SparkplugDriver(SpTag(SpTempPath, SpBlob(SpDevice, "Temperature")));
var fired = 0;
((IRediscoverable)driver).OnRediscoveryNeeded += (_, _) => Interlocked.Increment(ref fired);
FeedNodeBirth(driver);
FeedDeviceBirth(driver, seq: 1, ("Temperature", 5UL, TahuDataType.Float));
fired.ShouldBeGreaterThan(0);
}
///
/// The primary anti-storm pin. An edge node that re-births on a timer (or a flapping
/// connection driving the late-join rebirth) republishes the same metric set over and over. If
/// every birth fired rediscovery, each one would trigger an address-space rebuild — expensive
/// and disruptive on a running server, forever, for a tree that never changed.
///
[Fact]
public void IdenticalRebirth_DoesNotFireRediscovery()
{
var driver = SparkplugDriver(SpTag(SpTempPath, SpBlob(SpDevice, "Temperature")));
FeedNodeBirth(driver);
FeedDeviceBirth(driver, seq: 1, ("Temperature", 5UL, TahuDataType.Float));
// Subscribe only AFTER the first birth so the count below is purely the rebirths'.
var fired = 0;
((IRediscoverable)driver).OnRediscoveryNeeded += (_, _) => Interlocked.Increment(ref fired);
for (var i = 0; i < 5; i++)
{
FeedNodeBirth(driver, seq: 0, bdSeq: (ulong)(i + 2));
FeedDeviceBirth(driver, seq: 1, ("Temperature", 5UL, TahuDataType.Float));
}
fired.ShouldBe(0);
}
///
/// The other half of the gate: a rebirth that actually changes the metric set (a metric added,
/// removed or renamed) DOES change what discovery can resolve, and must fire.
///
[Fact]
public void RebirthWithChangedMetricSet_FiresRediscovery()
{
var driver = SparkplugDriver(
SpTag(SpTempPath, SpBlob(SpDevice, "Temperature")),
SpTag(SpPressPath, SpBlob(SpDevice, "Pressure")));
FeedNodeBirth(driver);
FeedDeviceBirth(driver, seq: 1, ("Temperature", 5UL, TahuDataType.Float));
var fired = 0;
((IRediscoverable)driver).OnRediscoveryNeeded += (_, _) => Interlocked.Increment(ref fired);
FeedDeviceBirth(driver, seq: 2, ("Temperature", 5UL, TahuDataType.Float), ("Pressure", 6UL, TahuDataType.Float));
fired.ShouldBe(1);
}
///
/// Metric ORDER is not the metric SET. A birth that lists the same metrics in a different order
/// describes the same address space; firing on it would reintroduce the rebuild storm through
/// the back door, because nothing obliges an edge node to keep its birth order stable.
///
[Fact]
public void RebirthReorderingTheSameMetrics_DoesNotFireRediscovery()
{
var driver = SparkplugDriver(
SpTag(SpTempPath, SpBlob(SpDevice, "Temperature")),
SpTag(SpPressPath, SpBlob(SpDevice, "Pressure")));
FeedNodeBirth(driver);
FeedDeviceBirth(driver, seq: 1, ("Temperature", 5UL, TahuDataType.Float), ("Pressure", 6UL, TahuDataType.Float));
var fired = 0;
((IRediscoverable)driver).OnRediscoveryNeeded += (_, _) => Interlocked.Increment(ref fired);
FeedDeviceBirth(driver, seq: 2, ("Pressure", 6UL, TahuDataType.Float), ("Temperature", 5UL, TahuDataType.Float));
fired.ShouldBe(0);
}
///
/// A birth for a device NO authored tag names cannot change 's
/// output — discovery replays the authored set. A large plant DBIRTHs devices this deployment
/// never reads on their own schedules; firing for those is a rebuild storm sourced from traffic
/// the configuration has no opinion about.
///
[Fact]
public void BirthForAnUnauthoredDevice_DoesNotFireRediscovery()
{
var driver = SparkplugDriver(SpTag(SpTempPath, SpBlob(SpDevice, "Temperature")));
FeedNodeBirth(driver);
var fired = 0;
((IRediscoverable)driver).OnRediscoveryNeeded += (_, _) => Interlocked.Increment(ref fired);
// Same (authored) edge node, a device nothing binds — the ingestor still applies the birth.
FeedDeviceBirth(driver, "Filler99", seq: 1, ("Temperature", 5UL, TahuDataType.Float));
fired.ShouldBe(0);
}
///
/// The rediscovery event names the subtree this driver's discovery actually emits (the
/// Mqtt folder) so a consumer scoping a rebuild on it scopes to exactly the tree that
/// changed, and carries the Sparkplug scope in the diagnostic reason.
///
[Fact]
public void RediscoveryEvent_ScopeHintIsTheDiscoveryFolder_ReasonNamesTheScope()
{
var driver = SparkplugDriver(SpTag(SpTempPath, SpBlob(SpDevice, "Temperature")));
RediscoveryEventArgs? args = null;
((IRediscoverable)driver).OnRediscoveryNeeded += (_, e) => args = e;
FeedNodeBirth(driver);
FeedDeviceBirth(driver, seq: 1, ("Temperature", 5UL, TahuDataType.Float));
args.ShouldNotBeNull();
args.ScopeHint.ShouldBe("Mqtt");
args.Reason.ShouldContain($"{SpGroup}/{SpNode}/{SpDevice}");
}
///
/// The datatype fill-in. dataType is optional in the Sparkplug tag shape — the
/// birth certificate declares it. Before any birth the tag can only report the record default;
/// once the DBIRTH lands, discovery must re-stream it with the type the birth declared.
///
[Fact]
public async Task DiscoverAsync_Sparkplug_UnauthoredDataType_FillsInFromTheBirth()
{
var driver = SparkplugDriver(SpTag(SpTempPath, SpBlob(SpDevice, "Temperature")));
var before = new CapturingAddressSpaceBuilder();
await driver.DiscoverAsync(before, TestContext.Current.CancellationToken);
// The node exists from the first pass — an authored tag is part of the declared configuration,
// not something the plant grants by publishing.
before.Variables.Select(v => v.Info.FullName).ShouldBe([SpTempPath]);
before.Variables[0].Info.DriverDataType.ShouldBe(DriverDataType.String); // the record default
FeedNodeBirth(driver);
FeedDeviceBirth(driver, seq: 1, ("Temperature", 5UL, TahuDataType.Float));
var after = new CapturingAddressSpaceBuilder();
await driver.DiscoverAsync(after, TestContext.Current.CancellationToken);
after.Variables.Select(v => v.Info.FullName).ShouldBe([SpTempPath]);
after.Variables[0].Info.DriverDataType.ShouldBe(DriverDataType.Float32);
}
///
/// The authored dataType wins over the birth's — the same precedence the ingest path
/// applies when it coerces a value (DataTypeAuthored ? authored : birth). A discovery
/// surface that disagreed with the publish surface would report a type no value ever arrives as.
///
[Fact]
public async Task DiscoverAsync_Sparkplug_AuthoredDataType_WinsOverTheBirth()
{
var driver = SparkplugDriver(SpTag(SpTempPath, SpBlob(SpDevice, "Temperature", "Int32")));
FeedNodeBirth(driver);
FeedDeviceBirth(driver, seq: 1, ("Temperature", 5UL, TahuDataType.Float));
var b = new CapturingAddressSpaceBuilder();
await driver.DiscoverAsync(b, TestContext.Current.CancellationToken);
b.Variables[0].Info.DriverDataType.ShouldBe(DriverDataType.Int32);
}
///
/// A birth declaring a Sparkplug type this driver cannot map (DataSet / Template / PropertySet
/// / Unknown) must not blank the tag's type — ToDriverDataType() returns null for those,
/// and discovery falls back to the authored/default type rather than to nothing.
///
[Fact]
public async Task DiscoverAsync_Sparkplug_UnsupportedBirthType_FallsBackToTheDefault()
{
var driver = SparkplugDriver(SpTag(SpTempPath, SpBlob(SpDevice, "Temperature")));
FeedNodeBirth(driver);
FeedDeviceBirth(driver, seq: 1, ("Temperature", 5UL, TahuDataType.DataSet));
var b = new CapturingAddressSpaceBuilder();
await driver.DiscoverAsync(b, TestContext.Current.CancellationToken);
b.Variables.Count.ShouldBe(1);
b.Variables[0].Info.DriverDataType.ShouldBe(DriverDataType.String);
}
///
/// The composition-root line. Task 21 wires the ingestor's OnDataChange to the
/// driver's own in CreateSparkplugIngestor; every other Sparkplug test in the repo drives
/// directly and would pass with that lambda deleted. This repo
/// has twice shipped a component correct in isolation and inert in production for exactly that
/// reason (DeferredAddressSpaceSink, GatewayTagProvisioner) — so the re-raise is
/// asserted through 's own event, under the RawPath.
///
[Fact]
public async Task SparkplugMode_DriverRepublishesIngestDataChanges_UnderTheRawPath()
{
var driver = SparkplugDriver(SpTag(SpTempPath, SpBlob(SpDevice, "Temperature", "Float32")));
await driver.SubscribeAsync([SpTempPath], TimeSpan.Zero, TestContext.Current.CancellationToken);
object? sender = null;
string? firedRef = null;
object? firedValue = null;
((ISubscribable)driver).OnDataChange += (s, e) =>
{
sender = s;
firedRef = e.FullReference;
firedValue = e.Snapshot.Value;
};
FeedNodeBirth(driver);
FeedDeviceBirth(driver, seq: 1, ("Temperature", 5UL, TahuDataType.Float));
FeedDeviceData(driver, seq: 2, (5UL, 21.5f));
firedRef.ShouldBe(SpTempPath);
firedValue.ShouldBe(21.5f);
// The driver re-raises with ITSELF as sender, not the ingestor — DriverHostActor attributes
// publishes to the driver instance.
sender.ShouldBeSameAs(driver);
}
///
/// ReadAsync serves from the driver-owned LastValueCache, which the Sparkplug
/// ingestor is handed at construction. If the two ever stopped sharing one instance, every
/// Sparkplug read would answer BadWaitingForInitialData forever while subscriptions
/// looked perfectly healthy.
///
[Fact]
public async Task SparkplugMode_ReadAsyncServesTheIngestedValue()
{
var driver = SparkplugDriver(SpTag(SpTempPath, SpBlob(SpDevice, "Temperature", "Float32")));
FeedNodeBirth(driver);
FeedDeviceBirth(driver, seq: 1, ("Temperature", 5UL, TahuDataType.Float));
FeedDeviceData(driver, seq: 2, (5UL, 33.5f));
var results = await driver.ReadAsync([SpTempPath], TestContext.Current.CancellationToken);
results[0].Value.ShouldBe(33.5f);
results[0].StatusCode.ShouldBe(0x00000000u);
}
///
/// Subscribe/unsubscribe dispatch by mode. After an unsubscribe the reference is no longer
/// attributed to a handle, so ingest stops raising for it — while the cache keeps answering, so
/// a read still works. Both halves matter: a driver that kept publishing after unsubscribe
/// would leak notifications into a torn-down monitored item.
///
[Fact]
public async Task SparkplugMode_UnsubscribeStopsNotifications_ButNotReads()
{
var driver = SparkplugDriver(SpTag(SpTempPath, SpBlob(SpDevice, "Temperature", "Float32")));
var handle = await driver.SubscribeAsync([SpTempPath], TimeSpan.Zero, TestContext.Current.CancellationToken);
FeedNodeBirth(driver);
FeedDeviceBirth(driver, seq: 1, ("Temperature", 5UL, TahuDataType.Float));
var fired = 0;
((ISubscribable)driver).OnDataChange += (_, _) => Interlocked.Increment(ref fired);
await driver.UnsubscribeAsync(handle, TestContext.Current.CancellationToken);
FeedDeviceData(driver, seq: 2, (5UL, 7.5f));
fired.ShouldBe(0);
(await driver.ReadAsync([SpTempPath], TestContext.Current.CancellationToken))[0].Value.ShouldBe(7.5f);
}
///
/// The mode gate from the other side — the reviewer's reading of the branching, turned into a
/// test. A Plain-mode driver builds NO Sparkplug ingestor at all, so no Sparkplug traffic can
/// reach it and a Sparkplug-shaped blob (which carries no topic) resolves to nothing.
///
[Fact]
public void PlainMode_BuildsNoSparkplugIngestor()
{
var driver = new MqttDriver(
new MqttDriverOptions
{
Mode = MqttMode.Plain,
RawTags = [SpTag(SpTempPath, SpBlob(SpDevice, "Temperature", "Float32"))],
},
"d",
null);
driver.Sparkplug.ShouldBeNull();
driver.Subscriptions.TryResolve(SpTempPath, out _).ShouldBeFalse();
}
///
/// …and the converse: a Sparkplug-mode driver registers into the Sparkplug path, not the plain
/// manager. Registering into both would double-warn on every deploy and, worse, let a plain
/// topic route a Sparkplug tag.
///
[Fact]
public void SparkplugMode_RegistersIntoTheSparkplugPathOnly()
{
var driver = SparkplugDriver(SpTag(SpTempPath, SpBlob(SpDevice, "Temperature", "Float32")));
driver.Sparkplug!.TryResolve(SpTempPath, out var def).ShouldBeTrue();
def.MetricName.ShouldBe("Temperature");
driver.Subscriptions.TryResolve(SpTempPath, out _).ShouldBeFalse();
}
// ---- Sparkplug fixtures ----
private const string SpGroup = "Plant1";
private const string SpNode = "EdgeA";
private const string SpDevice = "Filler1";
private const string SpTempPath = "Plant/Mqtt/spb/Filler1Temp";
private const string SpPressPath = "Plant/Mqtt/spb/Filler1Press";
/// A Sparkplug tag blob. dataType is omitted unless supplied — it is optional.
private static string SpBlob(string? device, string metric, string? dataType = null)
{
var deviceKey = device is null ? "" : $"""{'"'}deviceId{'"'}:"{device}",""";
var typeKey = dataType is null ? "" : $""","dataType":"{dataType}" """;
return $$"""{"groupId":"{{SpGroup}}","edgeNodeId":"{{SpNode}}",{{deviceKey}}"metricName":"{{metric}}"{{typeKey}}}""";
}
private static RawTagEntry SpTag(string rawPath, string blob) => new(rawPath, blob, WriteIdempotent: false);
private static MqttDriver SparkplugDriver(params RawTagEntry[] tags)
=> new(
new MqttDriverOptions
{
Mode = MqttMode.SparkplugB,
Sparkplug = new MqttSparkplugOptions { GroupId = SpGroup },
RawTags = tags,
},
"d",
null);
///
/// Feeds a real NBIRTH through the real codec + ingest state machine — no broker, and no
/// test-only shortcut on the driver. Dispatch is the method MQTTnet's dispatcher thread
/// reaches, so this exercises the production path end to end.
///
private static void FeedNodeBirth(MqttDriver driver, ulong seq = 0, ulong bdSeq = 1)
{
var payload = new Payload { Seq = seq, Timestamp = 1721822400000UL };
payload.Metrics.Add(new Payload.Types.Metric
{
Name = "bdSeq",
Datatype = (uint)TahuDataType.Uint64,
LongValue = bdSeq,
});
payload.Metrics.Add(SpBirthMetric("Uptime", 7UL, TahuDataType.Int64));
driver.Sparkplug!.Dispatch(
new SparkplugTopic(SparkplugMessageType.NBIRTH, SpGroup, SpNode, null, null),
SparkplugCodec.Decode(payload.ToByteArray()));
}
private static void FeedDeviceBirth(
MqttDriver driver,
ulong seq,
params (string Name, ulong Alias, TahuDataType Type)[] metrics)
=> FeedDeviceBirth(driver, SpDevice, seq, metrics);
private static void FeedDeviceBirth(
MqttDriver driver,
string device,
ulong seq,
params (string Name, ulong Alias, TahuDataType Type)[] metrics)
{
var payload = new Payload { Seq = seq, Timestamp = 1721822400000UL };
foreach (var (name, alias, type) in metrics)
{
payload.Metrics.Add(SpBirthMetric(name, alias, type));
}
driver.Sparkplug!.Dispatch(
new SparkplugTopic(SparkplugMessageType.DBIRTH, SpGroup, SpNode, device, null),
SparkplugCodec.Decode(payload.ToByteArray()));
}
///
/// Feeds a real DDATA — alias-only metrics, exactly as a Sparkplug node publishes after a birth.
///
private static void FeedDeviceData(MqttDriver driver, ulong seq, params (ulong Alias, float Value)[] metrics)
{
var payload = new Payload { Seq = seq, Timestamp = 1721822405000UL };
foreach (var (alias, value) in metrics)
{
payload.Metrics.Add(new Payload.Types.Metric { Alias = alias, FloatValue = value });
}
driver.Sparkplug!.Dispatch(
new SparkplugTopic(SparkplugMessageType.DDATA, SpGroup, SpNode, SpDevice, null),
SparkplugCodec.Decode(payload.ToByteArray()));
}
///
/// A birth metric declaring name/alias/datatype and no value — a birth's job here is to
/// declare the catalog, and these tests assert on the catalog, never on a published value.
///
private static Payload.Types.Metric SpBirthMetric(string name, ulong alias, TahuDataType type)
=> new() { Name = name, Alias = alias, Datatype = (uint)type };
// ---- test doubles ----
///
/// Records the streamed discovery tree instead of materializing OPC UA nodes. Local to this
/// suite: the driver test project does not reference Commons, where the runtime's own
/// capturing builder lives. Mirrors the per-driver RecordingBuilder the AB CIP / FOCAS
/// suites use.
///
private sealed class CapturingAddressSpaceBuilder : IAddressSpaceBuilder
{
/// Every folder streamed, in order, across the whole tree.
public List<(string BrowseName, string DisplayName)> Folders { get; } = [];
/// Every variable streamed, in order, across the whole tree.
public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = [];
///
public IAddressSpaceBuilder Folder(string browseName, string displayName)
{
Folders.Add((browseName, displayName));
return this;
}
///
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
{
Variables.Add((browseName, attributeInfo));
return new Handle(attributeInfo.FullName);
}
///
public void AddProperty(string browseName, DriverDataType dataType, object? value) { }
private sealed class Handle(string fullRef) : IVariableHandle
{
public string FullReference => fullRef;
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink();
}
private sealed class NullSink : IAlarmConditionSink
{
public void OnTransition(AlarmEventArgs args) { }
}
}
}