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;
///
/// Covers (Task 21) — the design doc's §3.6 correctness matrix:
/// rebirth, missed birth, seq gap, alias reuse across a rebirth, death → stale → rebirth, plus
/// data-before-birth and unknown-alias. Every test runs without a broker: payloads are built with
/// the generated Tahu types, decoded through the real , and fed to
/// (the method
/// calls on MQTTnet's dispatcher thread).
///
///
/// The identity assertion is the point of half of these. The plan's illustrative snippet
/// asserts firedRef.ShouldContain("Filler1:Temperature") — the retired pre-v3 shape. A
/// Sparkplug-composed reference satisfies it and is silently dead against DriverHostActor's
/// RawPath-keyed dual-namespace fan-out, so every publish here is asserted to be the RawPath
/// and to not be the composed form.
///
public sealed class SparkplugIngestorTests
{
// OPC UA status codes, verified against Opc.Ua.StatusCodes rather than recalled.
private const uint Good = 0x00000000u;
private const uint BadCommunicationError = 0x80050000u;
private const uint BadTypeMismatch = 0x80740000u;
private const uint BadNodeIdUnknown = 0x80340000u;
private const string Group = "Plant1";
private const string Node = "EdgeA";
private const string Device = "Filler1";
private const string TempPath = "Plant/Mqtt/spb/Filler1Temp";
private const string PressPath = "Plant/Mqtt/spb/Filler1Press";
private const string CountPath = "Plant/Mqtt/spb/Filler1Count";
private const string NodeUptimePath = "Plant/Mqtt/spb/EdgeAUptime";
/// The retired pre-v3 composed shape — asserted ABSENT, never present.
private const string ComposedRef = "Plant1/EdgeA/Filler1:Temperature";
private const ulong TempAlias = 5UL;
private const ulong PressAlias = 6UL;
private const ulong CountAlias = 9UL;
// ---------------------------------------------------------------------------------
// §3.6 #1/#2 — birth → data, resolved BY ALIAS, published UNDER THE RAWPATH
// ---------------------------------------------------------------------------------
///
/// The plan's first snippet, corrected: the published reference is asserted to BE the tag's
/// RawPath, not merely to contain the composed Filler1:Temperature form.
///
[Fact]
public async Task BirthThenData_ResolvesByAlias_RaisesOnDataChangeUnderRawPath()
{
var ing = await NewSubscribedIngestorAsync();
string? firedRef = null;
object? firedValue = null;
ing.OnDataChange += (_, e) => { firedRef = e.FullReference; firedValue = e.Snapshot.Value; };
ing.Dispatch(Topic(SparkplugMessageType.NBIRTH), Nbirth(seq: 0, bdSeq: 1));
ing.Dispatch(Topic(SparkplugMessageType.DBIRTH, Device), Dbirth(seq: 1));
ing.Dispatch(
Topic(SparkplugMessageType.DDATA, Device),
Ddata(seq: 2, (TempAlias, TahuDataType.Float, 21.5f)));
firedRef.ShouldBe(TempPath);
firedRef.ShouldNotBe(ComposedRef);
firedValue.ShouldBe(21.5f);
}
///
/// Falsifiability control for the identity assertion above: the composed Sparkplug reference —
/// the shape the plan's own snippet would have accepted — must never be published.
///
[Fact]
public async Task PublishedReference_IsNeverTheComposedSparkplugForm()
{
var ing = await NewSubscribedIngestorAsync();
var refs = new List();
ing.OnDataChange += (_, e) => refs.Add(e.FullReference);
FeedBirths(ing);
ing.Dispatch(
Topic(SparkplugMessageType.DDATA, Device),
Ddata(seq: 2, (TempAlias, TahuDataType.Float, 21.5f)));
refs.ShouldNotBeEmpty();
refs.ShouldAllBe(r => r == TempPath || r == PressPath || r == CountPath || r == NodeUptimePath);
refs.ShouldNotContain(ComposedRef);
}
/// A DATA metric carries an alias and NO name — the shape every real Sparkplug DATA has.
[Fact]
public async Task DataMetric_CarriesNoNameOrDatatype_StillResolves()
{
var ing = await NewSubscribedIngestorAsync();
FeedBirths(ing);
var payload = Ddata(seq: 2, (TempAlias, TahuDataType.Float, 33.5f));
payload.Metrics.ShouldAllBe(m => m.Name == null && m.DataType == null);
var seen = new Dictionary(StringComparer.Ordinal);
ing.OnDataChange += (_, e) => seen[e.FullReference] = e.Snapshot;
ing.Dispatch(Topic(SparkplugMessageType.DDATA, Device), payload);
seen[TempPath].Value.ShouldBe(33.5f);
seen[TempPath].StatusCode.ShouldBe(Good);
}
/// A node-scoped metric (NDATA) binds against the node's own scope, not a device's.
[Fact]
public async Task NodeScopedMetric_BindsToTheNodeScope()
{
var ing = await NewSubscribedIngestorAsync();
var seen = new Dictionary(StringComparer.Ordinal);
ing.OnDataChange += (_, e) => seen[e.FullReference] = e.Snapshot.Value;
ing.Dispatch(Topic(SparkplugMessageType.NBIRTH), Nbirth(seq: 0, bdSeq: 1));
ing.Dispatch(Topic(SparkplugMessageType.NDATA), Ndata(seq: 1, (7UL, TahuDataType.Int64, 99L)));
seen[NodeUptimePath].ShouldBe(99L);
seen.ShouldNotContainKey(TempPath);
}
// ---------------------------------------------------------------------------------
// §3.6 #1 — ALIAS REUSE ACROSS A REBIRTH (the mis-route this whole design prevents)
// ---------------------------------------------------------------------------------
///
/// The single most important test in the file. An edge node is free to point alias 5 at a
/// different metric after a rebirth. Binding a tag to an alias silently routes Pressure into the
/// Temperature tag — good quality, plausible value, completely wrong.
///
[Fact]
public async Task AliasReusedAcrossRebirth_RoutesByName_NotByAlias()
{
var ing = await NewSubscribedIngestorAsync();
var seen = new Dictionary(StringComparer.Ordinal);
ing.OnDataChange += (_, e) => seen[e.FullReference] = e.Snapshot.Value;
ing.Dispatch(Topic(SparkplugMessageType.NBIRTH), Nbirth(seq: 0, bdSeq: 1));
ing.Dispatch(Topic(SparkplugMessageType.DBIRTH, Device), Dbirth(seq: 1));
ing.Dispatch(
Topic(SparkplugMessageType.DDATA, Device),
Ddata(seq: 2, (TempAlias, TahuDataType.Float, 21.5f)));
seen[TempPath].ShouldBe(21.5f);
// Rebirth: the SAME alias 5 now names Pressure, and Temperature moves to alias 6.
ing.Dispatch(Topic(SparkplugMessageType.NBIRTH), Nbirth(seq: 0, bdSeq: 2));
ing.Dispatch(
Topic(SparkplugMessageType.DBIRTH, Device),
DbirthCustom(
seq: 1,
("Pressure", TempAlias, TahuDataType.Float, 1.0f),
("Temperature", PressAlias, TahuDataType.Float, 2.0f)));
seen.Clear();
ing.Dispatch(
Topic(SparkplugMessageType.DDATA, Device),
Ddata(seq: 2, (TempAlias, TahuDataType.Float, 4.25f)));
// Alias 5 is Pressure NOW. A tag bound by alias would put 4.25 on the Temperature RawPath.
seen.ShouldContainKey(PressPath);
seen[PressPath].ShouldBe(4.25f);
seen.ShouldNotContainKey(TempPath);
}
///
/// A rebirth REPLACES the catalog: an alias the new birth does not declare must stop resolving,
/// rather than lingering from the previous birth's table.
///
[Fact]
public async Task Rebirth_DroppingAMetric_StopsFeedingIt()
{
var ing = await NewSubscribedIngestorAsync();
var seen = new List();
ing.OnDataChange += (_, e) => seen.Add(e.FullReference);
FeedBirths(ing);
ing.Dispatch(Topic(SparkplugMessageType.NBIRTH), Nbirth(seq: 0, bdSeq: 2));
ing.Dispatch(
Topic(SparkplugMessageType.DBIRTH, Device),
DbirthCustom(seq: 1, ("Pressure", PressAlias, TahuDataType.Float, 0f)));
seen.Clear();
ing.Dispatch(
Topic(SparkplugMessageType.DDATA, Device),
Ddata(seq: 2, (TempAlias, TahuDataType.Float, 21.5f)));
seen.ShouldNotContain(TempPath);
}
// ---------------------------------------------------------------------------------
// §3.6 #3 — data before birth / unknown alias / seq gap ⇒ rebirth NCMD
// ---------------------------------------------------------------------------------
/// The plan's second snippet, against the real NCMD publish seam.
[Fact]
public async Task DataBeforeBirth_RequestsRebirth()
{
var publish = new RecordingPublishTransport();
var ing = await NewSubscribedIngestorAsync(publish);
ing.Dispatch(
Topic(SparkplugMessageType.DDATA, Device),
Ddata(seq: 0, (CountAlias, TahuDataType.Float, 1f)));
await ing.DrainPendingRebirthsAsync();
publish.Topics.ShouldContain("spBv1.0/Plant1/NCMD/EdgeA");
}
/// An alias the current birth never declared is our metadata being behind the node's.
[Fact]
public async Task UnknownAlias_RequestsRebirth()
{
var publish = new RecordingPublishTransport();
var ing = await NewSubscribedIngestorAsync(publish);
FeedBirths(ing);
publish.Topics.Clear();
ing.Dispatch(
Topic(SparkplugMessageType.DDATA, Device),
Ddata(seq: 2, (4242UL, TahuDataType.Float, 1f)));
await ing.DrainPendingRebirthsAsync();
publish.Topics.ShouldContain("spBv1.0/Plant1/NCMD/EdgeA");
}
/// A missed message in the edge node's stream — §3.6 #3.
[Fact]
public async Task SeqGap_RequestsRebirth()
{
var publish = new RecordingPublishTransport();
var ing = await NewSubscribedIngestorAsync(publish);
FeedBirths(ing);
publish.Topics.Clear();
// seq 2 was expected; 4 means two messages were lost.
ing.Dispatch(
Topic(SparkplugMessageType.DDATA, Device),
Ddata(seq: 4, (TempAlias, TahuDataType.Float, 1f)));
await ing.DrainPendingRebirthsAsync();
publish.Topics.ShouldContain("spBv1.0/Plant1/NCMD/EdgeA");
}
///
/// Falsifiability control for every rebirth test above: a contiguous, birth-synchronised stream
/// must publish NOTHING. Without this the rebirth assertions could pass off a driver that
/// NCMDs on every single message.
///
[Fact]
public async Task ContiguousStream_RequestsNoRebirth()
{
var publish = new RecordingPublishTransport();
var ing = await NewSubscribedIngestorAsync(publish);
FeedBirths(ing);
ing.Dispatch(Topic(SparkplugMessageType.DDATA, Device), Ddata(seq: 2, (TempAlias, TahuDataType.Float, 1f)));
ing.Dispatch(Topic(SparkplugMessageType.DDATA, Device), Ddata(seq: 3, (TempAlias, TahuDataType.Float, 2f)));
await ing.DrainPendingRebirthsAsync();
publish.Topics.ShouldBeEmpty();
}
///
/// 255 → 0 is the sequence continuing, not a gap. Reading the wrap as a gap asks every edge node
/// to rebirth once per 256 messages, so a busy node spends its life republishing births.
///
[Fact]
public async Task SeqWrapsFrom255To0_IsNotAGap()
{
var publish = new RecordingPublishTransport();
var ing = await NewSubscribedIngestorAsync(publish);
ing.Dispatch(Topic(SparkplugMessageType.NBIRTH), Nbirth(seq: 255, bdSeq: 1));
ing.Dispatch(Topic(SparkplugMessageType.DBIRTH, Device), Dbirth(seq: 0));
ing.Dispatch(Topic(SparkplugMessageType.DDATA, Device), Ddata(seq: 1, (TempAlias, TahuDataType.Float, 1f)));
await ing.DrainPendingRebirthsAsync();
publish.Topics.ShouldBeEmpty();
}
///
/// A data message whose sequence happens to be contiguous but which arrived with no NBIRTH seen
/// since connect — the driver joined mid-stream and holds no alias table.
/// returns for it, so a driver that
/// only read the return value would sit happily behind a stream it cannot decode.
///
[Fact]
public async Task LateJoin_FirstMessageIsNotABirth_RequestsRebirth()
{
var publish = new RecordingPublishTransport();
var ing = await NewSubscribedIngestorAsync(publish);
ing.Dispatch(Topic(SparkplugMessageType.DBIRTH, Device), Dbirth(seq: 17));
await ing.DrainPendingRebirthsAsync();
publish.Topics.ShouldContain("spBv1.0/Plant1/NCMD/EdgeA");
}
///
/// Repeatedly commanding a node that is already republishing IS the storm the design warns
/// about. Three unroutable messages in a row must produce one NCMD, not three.
///
[Fact]
public async Task RepeatedRebirthTriggers_AreDebouncedPerEdgeNode()
{
var publish = new RecordingPublishTransport();
var ing = await NewSubscribedIngestorAsync(publish, rebirthDebounce: TimeSpan.FromMinutes(5));
for (var i = 0; i < 3; i++)
{
ing.Dispatch(
Topic(SparkplugMessageType.DDATA, Device),
Ddata(seq: (ulong)i, (CountAlias, TahuDataType.Float, 1f)));
}
await ing.DrainPendingRebirthsAsync();
publish.Topics.Count.ShouldBe(1);
}
/// The debounce floor is per edge node, not global — a second node must not be starved.
[Fact]
public async Task RebirthDebounce_IsPerEdgeNode()
{
var publish = new RecordingPublishTransport();
var ing = await NewSubscribedIngestorAsync(
publish,
rebirthDebounce: TimeSpan.FromMinutes(5),
extraTags: [Tag("Plant/Mqtt/spb/EdgeBTemp", Blob(Group, "EdgeB", Device, "Temperature", "Float32"))]);
ing.Dispatch(Topic(SparkplugMessageType.DDATA, Device), Ddata(seq: 0, (CountAlias, TahuDataType.Float, 1f)));
ing.Dispatch(
new SparkplugTopic(SparkplugMessageType.DDATA, Group, "EdgeB", Device, null),
Ddata(seq: 0, (CountAlias, TahuDataType.Float, 1f)));
await ing.DrainPendingRebirthsAsync();
publish.Topics.ShouldContain("spBv1.0/Plant1/NCMD/EdgeA");
publish.Topics.ShouldContain("spBv1.0/Plant1/NCMD/EdgeB");
}
/// requestRebirthOnGap = false is the operator's off switch for ALL outbound NCMDs.
[Fact]
public async Task RequestRebirthOnGapDisabled_PublishesNothing()
{
var publish = new RecordingPublishTransport();
var ing = await NewSubscribedIngestorAsync(publish, requestRebirthOnGap: false);
ing.Dispatch(Topic(SparkplugMessageType.DDATA, Device), Ddata(seq: 0, (CountAlias, TahuDataType.Float, 1f)));
await ing.DrainPendingRebirthsAsync();
publish.Topics.ShouldBeEmpty();
}
/// A missing publish transport degrades to a log, never an exception on the dispatcher.
[Fact]
public async Task RebirthWithNoPublishTransport_DoesNotThrow()
{
var ing = await NewSubscribedIngestorAsync(publish: null);
Should.NotThrow(() => ing.Dispatch(
Topic(SparkplugMessageType.DDATA, Device),
Ddata(seq: 0, (CountAlias, TahuDataType.Float, 1f))));
}
// ---------------------------------------------------------------------------------
// §3.6 #4 — death → STALE, next birth restores Good
// ---------------------------------------------------------------------------------
/// The plan's third snippet.
[Fact]
public async Task Ddeath_EmitsBadForDeviceMetrics()
{
var ing = await NewSubscribedIngestorAsync();
FeedBirths(ing);
var quals = new List();
ing.OnDataChange += (_, e) => quals.Add(e.Snapshot.StatusCode);
ing.Dispatch(Topic(SparkplugMessageType.DDEATH, Device), Ddeath(seq: 2));
quals.ShouldNotBeEmpty();
quals.ShouldAllBe(q => q == BadCommunicationError);
}
/// The completing half of §3.6 #4 — a birth is a full snapshot, so it restores Good.
[Fact]
public async Task DdeathThenDbirth_RestoresGood()
{
var ing = await NewSubscribedIngestorAsync();
FeedBirths(ing);
var last = new Dictionary(StringComparer.Ordinal);
ing.OnDataChange += (_, e) => last[e.FullReference] = e.Snapshot;
ing.Dispatch(Topic(SparkplugMessageType.DDEATH, Device), Ddeath(seq: 2));
last[TempPath].StatusCode.ShouldBe(BadCommunicationError);
ing.Dispatch(Topic(SparkplugMessageType.DBIRTH, Device), Dbirth(seq: 3));
last[TempPath].StatusCode.ShouldBe(Good);
last[TempPath].Value.ShouldBe(0f);
}
/// A DDEATH stales exactly its own device; the node's own metrics keep flowing.
[Fact]
public async Task Ddeath_DoesNotStaleTheNodesOwnMetrics()
{
var ing = await NewSubscribedIngestorAsync();
FeedBirths(ing);
var last = new Dictionary(StringComparer.Ordinal);
ing.OnDataChange += (_, e) => last[e.FullReference] = e.Snapshot;
ing.Dispatch(Topic(SparkplugMessageType.DDEATH, Device), Ddeath(seq: 2));
last.ShouldContainKey(TempPath);
last.ShouldNotContainKey(NodeUptimePath);
}
/// An NDEATH kills the node AND every device under it.
[Fact]
public async Task Ndeath_StalesTheNodeAndEveryDeviceUnderIt()
{
var ing = await NewSubscribedIngestorAsync();
FeedBirths(ing);
var last = new Dictionary(StringComparer.Ordinal);
ing.OnDataChange += (_, e) => last[e.FullReference] = e.Snapshot;
ing.Dispatch(Topic(SparkplugMessageType.NDEATH), Ndeath(bdSeq: 1));
last[TempPath].StatusCode.ShouldBe(BadCommunicationError);
last[NodeUptimePath].StatusCode.ShouldBe(BadCommunicationError);
}
///
/// The bdSeq tie of §3.6 #3. A node's connection drops, it reconnects and re-births, and
/// only THEN does the broker deliver the old session's Last Will. Acting on it marks a live node
/// dead with nothing coming to correct it.
///
[Fact]
public async Task StaleNdeath_FromAPreviousSession_IsIgnored()
{
var ing = await NewSubscribedIngestorAsync();
ing.Dispatch(Topic(SparkplugMessageType.NBIRTH), Nbirth(seq: 0, bdSeq: 2));
ing.Dispatch(Topic(SparkplugMessageType.DBIRTH, Device), Dbirth(seq: 1));
var quals = new List();
ing.OnDataChange += (_, e) => quals.Add(e.Snapshot.StatusCode);
ing.Dispatch(Topic(SparkplugMessageType.NDEATH), Ndeath(bdSeq: 1));
quals.ShouldBeEmpty();
ing.Births.Find(new SparkplugScope(Group, Node, Device)).ShouldNotBeNull();
}
///
/// NDEATH must never reach . It is the broker-published
/// Last Will and carries no seq at all, so Accept would report a gap on every death
/// and answer a node that has just died with a rebirth request it can never serve.
///
[Fact]
public async Task Ndeath_DoesNotAdvanceTheSequenceTracker_AndRequestsNoRebirth()
{
var publish = new RecordingPublishTransport();
var ing = await NewSubscribedIngestorAsync(publish);
FeedBirths(ing);
publish.Topics.Clear();
ing.Dispatch(Topic(SparkplugMessageType.NDEATH), Ndeath(bdSeq: 1));
await ing.DrainPendingRebirthsAsync();
// This single assertion IS the complete check, and the review question "does it also verify the
// baseline?" has an answer rather than a gap: SequenceTracker.Accept refuses an absent seq
// WITHOUT adopting it as the baseline, so routing the death through it has exactly one
// observable consequence — a reported gap, and therefore this NCMD. Falsifiability run (b)
// confirmed it: inserting CheckSequence into OnNodeDeath reddens this test and only this test.
//
// A follow-up DDATA would NOT strengthen it. An accepted NDEATH evicts the node's births, so
// the next data message is legitimately data-before-birth and asks for a rebirth on its own
// merits — asserting silence there would pin the wrong behaviour.
publish.Topics.ShouldBeEmpty();
}
///
/// Per the Sparkplug spec an NBIRTH voids every prior DBIRTH under that node: those devices MUST
/// re-DBIRTH, and until they do their aliases mean nothing.
///
[Fact]
public async Task Nbirth_InvalidatesPriorDeviceBirths_AndStalesThem()
{
var ing = await NewSubscribedIngestorAsync();
FeedBirths(ing);
var last = new Dictionary(StringComparer.Ordinal);
ing.OnDataChange += (_, e) => last[e.FullReference] = e.Snapshot;
ing.Dispatch(Topic(SparkplugMessageType.NBIRTH), Nbirth(seq: 0, bdSeq: 2));
last[TempPath].StatusCode.ShouldBe(BadCommunicationError);
ing.Births.Find(new SparkplugScope(Group, Node, Device)).ShouldBeNull();
}
// ---------------------------------------------------------------------------------
// Wire-level value correctness
// ---------------------------------------------------------------------------------
///
/// Sparkplug carries Int8/16/32/64 as two's complement in an unsigned proto field, so a
/// metric whose value is -43 arrives as 4294967253. Skipping
/// publishes a plausible, Good-quality,
/// completely wrong number — or, once the tag is Int32, an out-of-range refusal.
///
[Fact]
public async Task NegativeInteger_IsReinterpretedFromItsTwosComplementWireValue()
{
var ing = await NewSubscribedIngestorAsync();
FeedBirths(ing);
var seen = new Dictionary(StringComparer.Ordinal);
ing.OnDataChange += (_, e) => seen[e.FullReference] = e.Snapshot;
ing.Dispatch(
Topic(SparkplugMessageType.DDATA, Device),
Ddata(seq: 2, (CountAlias, TahuDataType.Int32, -43)));
seen[CountPath].StatusCode.ShouldBe(Good);
seen[CountPath].Value.ShouldBe(-43);
seen[CountPath].Value.ShouldNotBe(4294967253L);
}
/// The birth's own values are published too — that is how a rebirth restores Good.
[Fact]
public async Task BirthValues_ArePublished()
{
var ing = await NewSubscribedIngestorAsync();
var seen = new Dictionary(StringComparer.Ordinal);
ing.OnDataChange += (_, e) => seen[e.FullReference] = e.Snapshot.Value;
FeedBirths(ing);
seen[TempPath].ShouldBe(0f);
seen[CountPath].ShouldBe(-42);
}
///
/// A tag authored with no dataType takes the type its BIRTH declared — the reason
/// dataType is optional in the Sparkplug tag shape at all.
///
[Fact]
public async Task TagWithNoAuthoredDataType_TakesTheBirthsType()
{
const string Untyped = "Plant/Mqtt/spb/Untyped";
var ing = NewIngestor(tags: [Tag(Untyped, """{"groupId":"Plant1","edgeNodeId":"EdgeA","deviceId":"Filler1","metricName":"Temperature"}""")]);
await ing.SubscribeAsync([Untyped], TimeSpan.Zero, TestContext.Current.CancellationToken);
object? value = null;
ing.OnDataChange += (_, e) => value = e.Snapshot.Value;
ing.Dispatch(Topic(SparkplugMessageType.NBIRTH), Nbirth(seq: 0, bdSeq: 1));
ing.Dispatch(Topic(SparkplugMessageType.DBIRTH, Device), Dbirth(seq: 1));
ing.Dispatch(Topic(SparkplugMessageType.DDATA, Device), Ddata(seq: 2, (TempAlias, TahuDataType.Float, 7.5f)));
// Float32, from the birth — NOT the record's String default, which would have produced "7.5".
value.ShouldBe(7.5f);
}
/// An authored dataType wins over the birth's — the operator declared it deliberately.
[Fact]
public async Task AuthoredDataType_WinsOverTheBirths()
{
const string AsText = "Plant/Mqtt/spb/TempAsText";
var ing = NewIngestor(tags: [Tag(AsText, Blob(Group, Node, Device, "Temperature", "String"))]);
await ing.SubscribeAsync([AsText], TimeSpan.Zero, TestContext.Current.CancellationToken);
object? value = null;
ing.OnDataChange += (_, e) => value = e.Snapshot.Value;
ing.Dispatch(Topic(SparkplugMessageType.NBIRTH), Nbirth(seq: 0, bdSeq: 1));
ing.Dispatch(Topic(SparkplugMessageType.DBIRTH, Device), Dbirth(seq: 1));
ing.Dispatch(Topic(SparkplugMessageType.DDATA, Device), Ddata(seq: 2, (TempAlias, TahuDataType.Float, 7.5f)));
value.ShouldBe("7.5");
}
/// An explicit Sparkplug is_null is not a value of the tag's declared type.
[Fact]
public async Task ExplicitNullMetric_PublishesBadTypeMismatch()
{
var ing = await NewSubscribedIngestorAsync();
FeedBirths(ing);
var seen = new Dictionary(StringComparer.Ordinal);
ing.OnDataChange += (_, e) => seen[e.FullReference] = e.Snapshot;
var payload = new Payload { Seq = 2UL, Timestamp = 1UL };
payload.Metrics.Add(new Payload.Types.Metric { Alias = TempAlias, IsNull = true });
ing.Dispatch(Topic(SparkplugMessageType.DDATA, Device), Decode(payload));
seen[TempPath].StatusCode.ShouldBe(BadTypeMismatch);
seen[TempPath].Value.ShouldBeNull();
}
///
/// A DataSet/Template metric is warn-and-skip — NOT a missing metric and NOT a rebirth trigger.
/// A node asked to rebirth would answer with the same unsupported metric and the pair would loop.
///
[Fact]
public async Task UnsupportedMetricKind_IsSkipped_AndRequestsNoRebirth()
{
var publish = new RecordingPublishTransport();
var ing = await NewSubscribedIngestorAsync(publish);
FeedBirths(ing);
publish.Topics.Clear();
var seen = new List();
ing.OnDataChange += (_, e) => seen.Add(e.FullReference);
var payload = new Payload { Seq = 2UL, Timestamp = 1UL };
payload.Metrics.Add(new Payload.Types.Metric { Alias = TempAlias, DatasetValue = new Payload.Types.DataSet() });
ing.Dispatch(Topic(SparkplugMessageType.DDATA, Device), Decode(payload));
await ing.DrainPendingRebirthsAsync();
seen.ShouldBeEmpty();
publish.Topics.ShouldBeEmpty();
}
/// A value that does not fit the authored type is refused, never rounded or wrapped.
[Fact]
public async Task FractionalValueForAnIntegerTag_PublishesBadTypeMismatch()
{
const string IntTag = "Plant/Mqtt/spb/TempAsInt";
var ing = NewIngestor(tags: [Tag(IntTag, Blob(Group, Node, Device, "Temperature", "Int32"))]);
await ing.SubscribeAsync([IntTag], TimeSpan.Zero, TestContext.Current.CancellationToken);
DataValueSnapshot? snapshot = null;
ing.OnDataChange += (_, e) => snapshot = e.Snapshot;
ing.Dispatch(Topic(SparkplugMessageType.NBIRTH), Nbirth(seq: 0, bdSeq: 1));
ing.Dispatch(Topic(SparkplugMessageType.DBIRTH, Device), Dbirth(seq: 1));
ing.Dispatch(Topic(SparkplugMessageType.DDATA, Device), Ddata(seq: 2, (TempAlias, TahuDataType.Float, 21.5f)));
snapshot!.StatusCode.ShouldBe(BadTypeMismatch);
}
// ---------------------------------------------------------------------------------
// Robustness: nothing may throw or mutate state off bad input
// ---------------------------------------------------------------------------------
///
/// An undecodable body is not evidence of anything. It must not clear an alias table, advance a
/// sequence tracker, or evict a birth — a hostile publisher on a group-wide # subscription
/// would otherwise be able to blank the address space with garbage bytes.
///
[Fact]
public async Task InvalidPayload_MutatesNothing()
{
var publish = new RecordingPublishTransport();
var ing = await NewSubscribedIngestorAsync(publish);
FeedBirths(ing);
publish.Topics.Clear();
ing.HandleMessage("spBv1.0/Plant1/DDATA/EdgeA/Filler1", [0xDE, 0xAD, 0xBE, 0xEF], retained: false);
await ing.DrainPendingRebirthsAsync();
// The birth survived …
ing.Births.Find(new SparkplugScope(Group, Node, Device)).ShouldNotBeNull();
// … the sequence baseline survived (seq 2 is still contiguous, so no rebirth) …
var seen = new Dictionary(StringComparer.Ordinal);
ing.OnDataChange += (_, e) => seen[e.FullReference] = e.Snapshot.Value;
ing.Dispatch(Topic(SparkplugMessageType.DDATA, Device), Ddata(seq: 2, (TempAlias, TahuDataType.Float, 9f)));
await ing.DrainPendingRebirthsAsync();
seen[TempPath].ShouldBe(9f);
publish.Topics.ShouldBeEmpty();
}
/// An undecodable NDEATH must not kill a live node either.
[Fact]
public async Task InvalidNdeathPayload_DoesNotStale()
{
var ing = await NewSubscribedIngestorAsync();
FeedBirths(ing);
var quals = new List();
ing.OnDataChange += (_, e) => quals.Add(e.Snapshot.StatusCode);
ing.HandleMessage("spBv1.0/Plant1/NDEATH/EdgeA", [0x01, 0x02, 0x03], retained: false);
quals.ShouldBeEmpty();
}
///
/// Runs on MQTTnet's dispatcher thread: an escaping exception stalls delivery for every
/// subscription on the connection.
///
[Theory]
[InlineData("")]
[InlineData("not/a/sparkplug/topic")]
[InlineData("spBv1.0/Plant1/NBIRTH/EdgeA")]
[InlineData("spBv1.0/Plant1/DDATA/EdgeA/Filler1")]
[InlineData("spBv1.0/STATE/otopcua-host-1")]
[InlineData("spBv1.0")]
public async Task HandleMessage_NeverThrows(string topic)
{
var ing = await NewSubscribedIngestorAsync();
Should.NotThrow(() => ing.HandleMessage(topic, [], retained: false));
Should.NotThrow(() => ing.HandleMessage(topic, [0xFF, 0x00, 0x7F], retained: true));
}
/// A throwing subscriber is contained rather than escaping onto the dispatcher thread.
[Fact]
public async Task ThrowingSubscriber_IsContained()
{
var ing = await NewSubscribedIngestorAsync();
ing.OnDataChange += (_, _) => throw new InvalidOperationException("boom");
Should.NotThrow(() => FeedBirths(ing));
}
///
/// An oversize body is refused BEFORE any protobuf parse, and mutates nothing.
///
///
/// The payload is a genuine, decodable NBIRTH — the control below proves the very same
/// bytes birth the scope once the ceiling is raised. An earlier version of this test fed
/// new byte[64], which fails to parse anyway, so it held with the size check deleted.
///
[Fact]
public async Task OversizePayload_IsDroppedBeforeDecoding()
{
var wire = NbirthBytes(seq: 0, bdSeq: 1);
var ing = NewIngestor(maxPayloadBytes: wire.Length - 1);
await ing.SubscribeAsync([TempPath], TimeSpan.Zero, TestContext.Current.CancellationToken);
ing.HandleMessage("spBv1.0/Plant1/NBIRTH/EdgeA", wire, retained: false);
ing.Births.Count.ShouldBe(0);
}
///
/// Falsifiability control for the test above: the identical bytes, one byte of headroom, birth
/// the scope. Without this the oversize assertion is satisfied by any reason the payload failed.
///
[Fact]
public async Task PayloadExactlyAtTheCeiling_IsAccepted()
{
var wire = NbirthBytes(seq: 0, bdSeq: 1);
var ing = NewIngestor(maxPayloadBytes: wire.Length);
await ing.SubscribeAsync([TempPath], TimeSpan.Zero, TestContext.Current.CancellationToken);
ing.HandleMessage("spBv1.0/Plant1/NBIRTH/EdgeA", wire, retained: false);
ing.Births.Count.ShouldBe(1);
}
///
/// The driver subscribes spBv1.0/{group}/#, so a large plant delivers traffic for every
/// node in the group. Only authored edge nodes are processed — that is what bounds the tracker
/// and birth maps by configuration rather than by whatever the plant publishes.
///
[Fact]
public async Task UnauthoredEdgeNode_IsIgnoredEntirely()
{
var publish = new RecordingPublishTransport();
var ing = await NewSubscribedIngestorAsync(publish);
var fired = false;
ing.OnDataChange += (_, _) => fired = true;
ing.Dispatch(new SparkplugTopic(SparkplugMessageType.NBIRTH, Group, "StrangerNode", null, null), Nbirth(seq: 0, bdSeq: 1));
ing.Dispatch(
new SparkplugTopic(SparkplugMessageType.DDATA, Group, "StrangerNode", "Dev", null),
Ddata(seq: 1, (TempAlias, TahuDataType.Float, 1f)));
await ing.DrainPendingRebirthsAsync();
fired.ShouldBeFalse();
ing.Births.Count.ShouldBe(0);
publish.Topics.ShouldBeEmpty();
}
///
/// An unauthored DEVICE under an authored node still advances its edge node's sequence — dropping
/// it early would manufacture a gap on the very next authored message — and, because nothing in
/// that scope could ever be published, it must not demand a rebirth either. A spec-violating
/// device that publishes DDATA without ever DBIRTHing would otherwise drive a permanent NCMD at
/// the debounce cadence, forever.
///
[Fact]
public async Task UnauthoredDeviceUnderAnAuthoredNode_StillAdvancesTheSequence()
{
var publish = new RecordingPublishTransport();
var ing = await NewSubscribedIngestorAsync(publish);
FeedBirths(ing);
publish.Topics.Clear();
// seq 2 belongs to a device nobody authored …
ing.Dispatch(
new SparkplugTopic(SparkplugMessageType.DDATA, Group, Node, "OtherDevice", null),
Ddata(seq: 2, (TempAlias, TahuDataType.Float, 1f)));
// … and seq 3 must therefore still be contiguous.
ing.Dispatch(Topic(SparkplugMessageType.DDATA, Device), Ddata(seq: 3, (TempAlias, TahuDataType.Float, 2f)));
await ing.DrainPendingRebirthsAsync();
publish.Topics.ShouldBeEmpty();
}
// ---------------------------------------------------------------------------------
// Registration / resolution / read
// ---------------------------------------------------------------------------------
[Fact]
public void Register_MapsOnlySparkplugShapedBlobs()
{
var ing = NewIngestor(tags: []);
var mapped = ing.Register([
Tag(TempPath, Blob(Group, Node, Device, "Temperature", "Float32")),
Tag("Plant/Mqtt/spb/Plainish", """{"topic":"f/oven/temp","payloadFormat":"Scalar","dataType":"Float64"}"""),
Tag("Plant/Mqtt/spb/NoMetric", """{"groupId":"Plant1","edgeNodeId":"EdgeA"}"""),
]);
mapped.ShouldBe(1);
ing.TryResolve(TempPath, out var def).ShouldBeTrue();
def.MetricName.ShouldBe("Temperature");
def.Name.ShouldBe(TempPath);
}
[Fact]
public void Register_IsWholesale_ADroppedTagStopsResolving()
{
var ing = NewIngestor();
ing.TryResolve(TempPath, out _).ShouldBeTrue();
ing.Register([]);
ing.TryResolve(TempPath, out _).ShouldBeFalse();
}
[Fact]
public async Task SubscribeAsync_UnauthoredReference_ReportsBadNodeIdUnknown()
{
var ing = NewIngestor();
await ing.SubscribeAsync(["Plant/Mqtt/spb/NotAThing"], TimeSpan.Zero, TestContext.Current.CancellationToken);
ing.Values.Read("Plant/Mqtt/spb/NotAThing").StatusCode.ShouldBe(BadNodeIdUnknown);
}
[Fact]
public async Task UnsubscribedButAuthoredTag_IsCachedButNotRaised()
{
var ing = NewIngestor();
var fired = false;
ing.OnDataChange += (_, _) => fired = true;
FeedBirths(ing);
fired.ShouldBeFalse();
ing.Values.Read(TempPath).Value.ShouldBe(0f);
}
/// Two raw tags may legitimately project the same Sparkplug metric; both must be fed.
[Fact]
public async Task TwoTagsBoundToOneMetric_BothReceive()
{
const string Mirror = "Plant/Mqtt/spb/Filler1TempMirror";
var ing = NewIngestor(extraTags: [Tag(Mirror, Blob(Group, Node, Device, "Temperature", "Float32"))]);
await ing.SubscribeAsync([TempPath, Mirror], TimeSpan.Zero, TestContext.Current.CancellationToken);
var seen = new List();
ing.OnDataChange += (_, e) => seen.Add(e.FullReference);
FeedBirths(ing);
ing.Dispatch(Topic(SparkplugMessageType.DDATA, Device), Ddata(seq: 2, (TempAlias, TahuDataType.Float, 1f)));
seen.ShouldContain(TempPath);
seen.ShouldContain(Mirror);
}
// ---------------------------------------------------------------------------------
// Subscribe / reconnect
// ---------------------------------------------------------------------------------
[Fact]
public async Task EstablishAsync_SubscribesTheGroupAndStateFilters_AndRequestsLateJoinRebirth()
{
var subscribe = new FakeSubscribeTransport();
var publish = new RecordingPublishTransport();
var ing = NewIngestor(publish, subscribe, hostId: "otopcua-host-1");
await ing.EstablishAsync(TestContext.Current.CancellationToken);
await ing.DrainPendingRebirthsAsync();
subscribe.Filters.Select(f => f.Topic).ShouldBe(
["spBv1.0/Plant1/#", "spBv1.0/STATE/otopcua-host-1", "STATE/otopcua-host-1"]);
publish.Topics.ShouldContain("spBv1.0/Plant1/NCMD/EdgeA");
}
/// No host id ⇒ no STATE filter. This driver has no use for a stranger's host state.
[Fact]
public async Task EstablishAsync_WithNoHostId_SubscribesTheGroupFilterOnly()
{
var subscribe = new FakeSubscribeTransport();
var ing = NewIngestor(subscribe: subscribe, hostId: "");
await ing.EstablishAsync(TestContext.Current.CancellationToken);
subscribe.Filters.Select(f => f.Topic).ShouldBe(["spBv1.0/Plant1/#"]);
}
///
/// There is exactly one filter and it covers every tag, so a broker that refuses it leaves the
/// whole driver deaf — the one outcome that must be a visible fault rather than a healthy-looking
/// driver receiving nothing.
///
[Fact]
public async Task EstablishAsync_GroupFilterRefused_Throws()
{
var subscribe = new FakeSubscribeTransport { Reject = _ => true };
var ing = NewIngestor(subscribe: subscribe);
await Should.ThrowAsync(
() => ing.EstablishAsync(TestContext.Current.CancellationToken));
}
/// A refused STATE filter costs an observability signal, not the data plane.
[Fact]
public async Task EstablishAsync_StateFilterRefused_DoesNotThrow()
{
var subscribe = new FakeSubscribeTransport { Reject = t => t.Contains("STATE", StringComparison.Ordinal) };
var ing = NewIngestor(subscribe: subscribe, hostId: "otopcua-host-1");
await ing.EstablishAsync(TestContext.Current.CancellationToken);
}
///
/// Across an outage of unknown length an edge node may have restarted and rebound its aliases; a
/// DDATA resolved against the pre-outage table would route a value to whichever tag used to own
/// that alias.
///
[Fact]
public async Task OnReconnected_ClearsBirths_ResubscribesAndRequestsRebirth()
{
var subscribe = new FakeSubscribeTransport();
var publish = new RecordingPublishTransport();
var ing = NewIngestor(publish, subscribe);
await ing.SubscribeAsync([TempPath], TimeSpan.Zero, TestContext.Current.CancellationToken);
FeedBirths(ing);
ing.Births.Count.ShouldBeGreaterThan(0);
publish.Topics.Clear();
subscribe.Filters.Clear();
await ing.OnReconnectedAsync(TestContext.Current.CancellationToken);
await ing.DrainPendingRebirthsAsync();
ing.Births.Count.ShouldBe(0);
subscribe.Filters.Select(f => f.Topic).ShouldContain("spBv1.0/Plant1/#");
publish.Topics.ShouldContain("spBv1.0/Plant1/NCMD/EdgeA");
}
///
/// Reconnect is a driver-side event, not a statement about the plant: last-known values remain
/// the best available answer until the rebirth lands. Only a death stales a tag.
///
[Fact]
public async Task OnReconnected_DoesNotStaleValues()
{
var ing = await NewSubscribedIngestorAsync(new RecordingPublishTransport(), new FakeSubscribeTransport());
FeedBirths(ing);
await ing.OnReconnectedAsync(TestContext.Current.CancellationToken);
ing.Values.Read(TempPath).StatusCode.ShouldBe(Good);
}
/// After a reconnect the driver has no birth, so the first DDATA must ask for one.
[Fact]
public async Task AfterReconnect_DataResolvesAgainstNothing_AndRequestsRebirth()
{
var publish = new RecordingPublishTransport();
var ing = await NewSubscribedIngestorAsync(publish, new FakeSubscribeTransport(), rebirthDebounce: TimeSpan.Zero);
FeedBirths(ing);
await ing.OnReconnectedAsync(TestContext.Current.CancellationToken);
await ing.DrainPendingRebirthsAsync();
publish.Topics.Clear();
var fired = false;
ing.OnDataChange += (_, _) => fired = true;
ing.Dispatch(Topic(SparkplugMessageType.DDATA, Device), Ddata(seq: 2, (TempAlias, TahuDataType.Float, 1f)));
await ing.DrainPendingRebirthsAsync();
fired.ShouldBeFalse();
publish.Topics.ShouldContain("spBv1.0/Plant1/NCMD/EdgeA");
}
// ---------------------------------------------------------------------------------
// STATE (receive only — this driver never publishes one; see the ingestor's remarks)
// ---------------------------------------------------------------------------------
[Fact]
public async Task State_V30JsonPayload_IsObserved()
{
var ing = await NewSubscribedIngestorAsync();
ing.HandleMessage("spBv1.0/STATE/otopcua-host-1", """{"online":true,"timestamp":1721822400000}"""u8, retained: true);
ing.HostState!.HostId.ShouldBe("otopcua-host-1");
ing.HostState!.Online.ShouldBeTrue();
}
[Fact]
public async Task State_LegacyTextPayload_IsObserved()
{
var ing = await NewSubscribedIngestorAsync();
ing.HandleMessage("STATE/otopcua-host-1", "OFFLINE"u8, retained: true);
ing.HostState!.Online.ShouldBeFalse();
}
[Fact]
public async Task State_UnrecognisedPayload_IsIgnored()
{
var ing = await NewSubscribedIngestorAsync();
ing.HandleMessage("spBv1.0/STATE/otopcua-host-1", "maybe?"u8, retained: true);
ing.HostState.ShouldBeNull();
}
// ---------------------------------------------------------------------------------
// Task 22 seam
// ---------------------------------------------------------------------------------
///
/// The rediscovery seam Task 22 wires to IRediscoverable.OnRediscoveryNeeded. Deliberately
/// unwired in MqttDriver today — this asserts the event exists and carries the scope +
/// metric set that Task 22 needs to decide whether the tree changed.
///
[Fact]
public async Task BirthObserved_FiresWithTheScopeAndMetricNames()
{
var ing = await NewSubscribedIngestorAsync();
var observed = new List();
ing.BirthObserved += (_, e) => observed.Add(e);
FeedBirths(ing);
observed.Count.ShouldBe(2);
observed[0].Scope.ShouldBe(new SparkplugScope(Group, Node, null));
observed[1].Scope.ShouldBe(new SparkplugScope(Group, Node, Device));
observed[1].MetricNames.ShouldContain("Temperature");
}
// ---------------------------------------------------------------------------------
// Session-state reset — the seams where the view underneath a surviving cache changes
// ---------------------------------------------------------------------------------
///
/// C1. The ingestor OUTLIVES a session rebuild: SessionIdentity is a strict
/// superset of IngestIdentity, so changing only the broker Host / Port / ClientId /
/// credentials / TLS / a timeout re-establishes on this very instance. If the birth cache
/// survived that, an edge node which restarted during the changeover and rebound alias 5 from
/// Temperature to Pressure would have its first DDATA on the NEW session resolved against the
/// OLD table — Pressure's value under the Temperature RawPath, at Good quality.
///
[Fact]
public async Task EstablishAsync_DropsBirthsFromThePreviousSession()
{
var subscribe = new FakeSubscribeTransport();
var ing = await NewSubscribedIngestorAsync(new RecordingPublishTransport(), subscribe);
FeedBirths(ing);
ing.Births.Find(new SparkplugScope(Group, Node, Device)).ShouldNotBeNull();
await ing.EstablishAsync(TestContext.Current.CancellationToken);
ing.Births.Find(new SparkplugScope(Group, Node, Device)).ShouldBeNull();
}
///
/// The consequence, asserted on the wire rather than on the cache: after a session rebuild the
/// old alias must not resolve, so the first DDATA publishes nothing and asks for a rebirth.
///
[Fact]
public async Task AfterEstablish_OldAliasNoLongerResolves()
{
var publish = new RecordingPublishTransport();
var ing = await NewSubscribedIngestorAsync(publish, new FakeSubscribeTransport());
FeedBirths(ing);
await ing.EstablishAsync(TestContext.Current.CancellationToken);
// Drain BEFORE clearing: Establish's own late-join rebirth is dispatched off-thread, so a bare
// Clear() races it and the assertions below could be satisfied by that NCMD rather than by the
// one the reset is supposed to cause.
await ing.DrainPendingRebirthsAsync();
publish.Topics.Clear();
var fired = false;
ing.OnDataChange += (_, _) => fired = true;
ing.Dispatch(Topic(SparkplugMessageType.DDATA, Device), Ddata(seq: 2, (TempAlias, TahuDataType.Float, 1f)));
await ing.DrainPendingRebirthsAsync();
fired.ShouldBeFalse();
publish.Topics.ShouldContain("spBv1.0/Plant1/NCMD/EdgeA");
}
///
/// The sequence half of the same reset. A surviving baseline lets a fresh stream look contiguous
/// by coincidence — and, worse, leaves IsBirthSynchronized true, so the late-join branch
/// never fires and nothing asks for the metadata the driver no longer has.
///
[Fact]
public async Task EstablishAsync_DropsTheSequenceBaseline()
{
var publish = new RecordingPublishTransport();
var ing = await NewSubscribedIngestorAsync(publish, new FakeSubscribeTransport());
FeedBirths(ing);
await ing.EstablishAsync(TestContext.Current.CancellationToken);
// Drain BEFORE clearing: Establish's own late-join rebirth is dispatched off-thread, so a bare
// Clear() races it and the assertions below could be satisfied by that NCMD rather than by the
// one the reset is supposed to cause.
await ing.DrainPendingRebirthsAsync();
publish.Topics.Clear();
// Contiguous against the OLD baseline (the DBIRTH was seq 1) — must still be treated as a late
// join, because nothing about the new session's stream has been observed.
ing.Dispatch(Topic(SparkplugMessageType.DDATA, Device), Ddata(seq: 2, (TempAlias, TahuDataType.Float, 1f)));
await ing.DrainPendingRebirthsAsync();
publish.Topics.ShouldContain("spBv1.0/Plant1/NCMD/EdgeA");
}
///
/// AttachTo is the earliest seam at which the session changes, and a persistent-session
/// broker can push queued messages between CONNACK and our own SUBSCRIBE — so the reset happens
/// there too, not only at EstablishAsync.
///
[Fact]
public async Task AttachTo_DropsBirthsFromThePreviousSession()
{
var ing = await NewSubscribedIngestorAsync();
FeedBirths(ing);
await using var connection = new MqttConnection(
new MqttDriverOptions { Host = "127.0.0.1", Port = 1, UseTls = false },
"mqtt-spb-1");
ing.AttachTo(connection);
ing.Births.Count.ShouldBe(0);
}
///
/// C2. While an edge node is unauthored its traffic is dropped at Dispatch, so its
/// cached birth FREEZES rather than refreshing. Deploy A authors it, deploy B drops it, deploy C
/// re-adds it a week later having missed every rebirth in between — a surviving cache answers
/// with the week-old alias table and the next DDATA mis-routes at Good quality.
///
[Fact]
public async Task Register_DroppingAnEdgeNode_EvictsItsBirths()
{
var ing = await NewSubscribedIngestorAsync();
FeedBirths(ing);
ing.Births.Count.ShouldBeGreaterThan(0);
ing.Register([]); // deploy B: the node is no longer authored
ing.Register(DefaultTags()); // deploy C: it comes back
ing.Births.Count.ShouldBe(0);
}
/// The C2 consequence on the wire: the re-added node's old alias must not resolve.
[Fact]
public async Task AfterEdgeNodeLeftAndReturned_OldAliasNoLongerResolves()
{
var publish = new RecordingPublishTransport();
var ing = await NewSubscribedIngestorAsync(publish);
FeedBirths(ing);
ing.Register([]);
ing.Register(DefaultTags());
await ing.SubscribeAsync(
[.. ing.AuthoredRawPaths],
TimeSpan.Zero,
TestContext.Current.CancellationToken);
publish.Topics.Clear();
var fired = false;
ing.OnDataChange += (_, _) => fired = true;
ing.Dispatch(Topic(SparkplugMessageType.DDATA, Device), Ddata(seq: 2, (TempAlias, TahuDataType.Float, 1f)));
await ing.DrainPendingRebirthsAsync();
fired.ShouldBeFalse();
publish.Topics.ShouldContain("spBv1.0/Plant1/NCMD/EdgeA");
}
///
/// The narrower half of the C2 rule, asserted so a future "tidy-up" cannot widen it. A device
/// with no authored tags whose EDGE NODE is still authored keeps receiving traffic (the Dispatch
/// filter is node-level), so its birth is live, not frozen — evicting it would throw away
/// correct state and cost a rebirth round-trip the moment a later deploy authored a tag on it.
///
[Fact]
public async Task Register_KeepsBirthsForStillAuthoredEdgeNodes()
{
var ing = await NewSubscribedIngestorAsync();
FeedBirths(ing);
// Drops the Filler1 tags but keeps the node-level Uptime tag, so EdgeA stays authored.
ing.Register([Tag(NodeUptimePath, Blob(Group, Node, null, "Uptime", "Int64"))]);
ing.Births.Find(new SparkplugScope(Group, Node, Device)).ShouldNotBeNull();
ing.Births.Find(new SparkplugScope(Group, Node, null)).ShouldNotBeNull();
}
// ---------------------------------------------------------------------------------
// Rebirth-loop bounds
// ---------------------------------------------------------------------------------
///
/// An NBIRTH whose seq cannot be read leaves the tracker un-synchronised forever — asking
/// for another birth cannot fix it, because the node answers with the same unusable payload. The
/// driver must record that a birth ARRIVED and stop asking.
///
[Fact]
public async Task NbirthWithUnreadableSeq_DoesNotLoopRequestingRebirths()
{
var publish = new RecordingPublishTransport();
var ing = await NewSubscribedIngestorAsync(publish);
// seq is present but out of the 0-255 Sparkplug range, so AcceptNodeBirth refuses it.
var payload = new Payload { Seq = 9999UL, Timestamp = 1UL };
payload.Metrics.Add(BirthMetric("Uptime", 7UL, TahuDataType.Int64, 0L));
ing.Dispatch(Topic(SparkplugMessageType.NBIRTH), Decode(payload));
publish.Topics.Clear();
for (var i = 0; i < 5; i++)
{
ing.Dispatch(Topic(SparkplugMessageType.DBIRTH, Device), Dbirth(seq: (ulong)i));
}
await ing.DrainPendingRebirthsAsync();
publish.Topics.ShouldBeEmpty();
}
///
/// The residual loop the birth-seen flag cannot see: an NBIRTH that never reaches the state
/// machine — because it exceeded the payload ceiling, or because the node simply never sends
/// one. Nothing about asking again changes that.
///
///
/// Each of these messages raises two uncapped conditions in the pre-fix code — late join
/// AND data-before-birth — which is what made the first version of this test observe 23 NCMDs
/// against a cap of 3. The debounce caps the RATE, not the LIFETIME; only the per-node
/// consecutive cap ends the loop, and it has to cover every missing-metadata reason to do it.
///
[Fact]
public async Task NbirthNeverArriving_CapsConsecutiveMetadataRebirths()
{
var publish = new RecordingPublishTransport();
var ing = await NewSubscribedIngestorAsync(publish);
for (var i = 0; i < 20; i++)
{
ing.Dispatch(Topic(SparkplugMessageType.DDATA, Device), Ddata(seq: (ulong)i, (TempAlias, TahuDataType.Float, 1f)));
}
await ing.DrainPendingRebirthsAsync();
// Bounded, and bounded LOW — the cap, not "eventually stops".
publish.Topics.Count.ShouldBeLessThanOrEqualTo(3);
publish.Topics.ShouldNotBeEmpty();
}
/// A birth that DOES arrive re-arms the cap — the count is consecutive, not cumulative.
[Fact]
public async Task ArrivingNbirth_ResetsTheMetadataRebirthCap()
{
var publish = new RecordingPublishTransport();
var ing = await NewSubscribedIngestorAsync(publish);
for (var i = 0; i < 20; i++)
{
ing.Dispatch(Topic(SparkplugMessageType.DDATA, Device), Ddata(seq: (ulong)i, (TempAlias, TahuDataType.Float, 1f)));
}
await ing.OnReconnectedAsync(TestContext.Current.CancellationToken);
await ing.DrainPendingRebirthsAsync();
publish.Topics.Clear();
ing.Dispatch(Topic(SparkplugMessageType.DDATA, Device), Ddata(seq: 0, (TempAlias, TahuDataType.Float, 1f)));
await ing.DrainPendingRebirthsAsync();
publish.Topics.ShouldNotBeEmpty();
}
// ---------------------------------------------------------------------------------
// Value-level gates
// ---------------------------------------------------------------------------------
///
/// Array metrics slip past the unsupported-type gate, because ToDriverDataType maps every
/// *Array variant to its ELEMENT type and is therefore never null for one. The value
/// rides as a packed byte[], so a String-typed tag would publish base64 at GOOD quality.
///
[Fact]
public async Task ArrayMetric_IsSkipped_NotPublishedAsBase64()
{
const string ArrTag = "Plant/Mqtt/spb/TempArray";
var ing = NewIngestor(tags: [Tag(ArrTag, Blob(Group, Node, Device, "Temperature", "String"))]);
await ing.SubscribeAsync([ArrTag], TimeSpan.Zero, TestContext.Current.CancellationToken);
var seen = new List();
ing.OnDataChange += (_, e) => seen.Add(e.Snapshot);
ing.Dispatch(Topic(SparkplugMessageType.NBIRTH), Nbirth(seq: 0, bdSeq: 1));
var birth = new Payload { Seq = 1UL, Timestamp = 1UL };
birth.Metrics.Add(new Payload.Types.Metric
{
Name = "Temperature",
Alias = TempAlias,
Datatype = (uint)TahuDataType.FloatArray,
BytesValue = ByteString.CopyFrom([0x00, 0x00, 0x80, 0x3F]),
});
ing.Dispatch(Topic(SparkplugMessageType.DBIRTH, Device), Decode(birth));
seen.ShouldBeEmpty();
ing.Values.Read(ArrTag).StatusCode.ShouldNotBe(Good);
}
///
/// Convert.ToSingle saturates rather than throwing, so a Double of 1e300 onto a Float32
/// tag would publish +Infinity at GOOD quality — a number no gauge renders and no comparison
/// handles. A narrowing overflow is a refusal.
///
[Fact]
public async Task NarrowingOverflowToFloat32_IsRefused_NotPublishedAsInfinity()
{
const string F32 = "Plant/Mqtt/spb/Narrowed";
var ing = NewIngestor(tags: [Tag(F32, Blob(Group, Node, Device, "Big", "Float32"))]);
await ing.SubscribeAsync([F32], TimeSpan.Zero, TestContext.Current.CancellationToken);
DataValueSnapshot? snapshot = null;
ing.OnDataChange += (_, e) => snapshot = e.Snapshot;
ing.Dispatch(Topic(SparkplugMessageType.NBIRTH), Nbirth(seq: 0, bdSeq: 1));
ing.Dispatch(
Topic(SparkplugMessageType.DBIRTH, Device),
DbirthCustom(seq: 1, ("Big", 21UL, TahuDataType.Double, 1e300d)));
snapshot!.StatusCode.ShouldBe(BadTypeMismatch);
snapshot.Value.ShouldBeNull();
}
///
/// A publisher's OWN infinity is its value, not our overflow — it passes through, so the guard
/// above cannot be satisfied by blanket-refusing non-finite numbers.
///
[Fact]
public async Task PublishersOwnInfinity_IsPassedThrough()
{
const string F32 = "Plant/Mqtt/spb/Inf";
var ing = NewIngestor(tags: [Tag(F32, Blob(Group, Node, Device, "Big", "Float32"))]);
await ing.SubscribeAsync([F32], TimeSpan.Zero, TestContext.Current.CancellationToken);
DataValueSnapshot? snapshot = null;
ing.OnDataChange += (_, e) => snapshot = e.Snapshot;
ing.Dispatch(Topic(SparkplugMessageType.NBIRTH), Nbirth(seq: 0, bdSeq: 1));
ing.Dispatch(
Topic(SparkplugMessageType.DBIRTH, Device),
DbirthCustom(seq: 1, ("Big", 21UL, TahuDataType.Float, float.PositiveInfinity)));
snapshot!.StatusCode.ShouldBe(Good);
snapshot.Value.ShouldBe(float.PositiveInfinity);
}
///
/// The metric NAME is the binding key, so when a DATA metric carries both a name and an alias
/// that disagree, the name wins. Preferring the alias resolves to whichever metric currently
/// occupies that slot — the mis-route the binding rule exists to prevent.
///
[Fact]
public async Task DataMetricCarryingNameAndDisagreeingAlias_BindsByName()
{
var ing = await NewSubscribedIngestorAsync();
FeedBirths(ing);
var seen = new Dictionary(StringComparer.Ordinal);
ing.OnDataChange += (_, e) => seen[e.FullReference] = e.Snapshot.Value;
// Alias 6 is Pressure; the name says Temperature. The name is the identity that survives a
// rebirth, so this must land on the Temperature RawPath.
var payload = new Payload { Seq = 2UL, Timestamp = 1UL };
payload.Metrics.Add(new Payload.Types.Metric
{
Name = "Temperature",
Alias = PressAlias,
FloatValue = 3.5f,
});
ing.Dispatch(Topic(SparkplugMessageType.DDATA, Device), Decode(payload));
seen.ShouldContainKey(TempPath);
seen[TempPath].ShouldBe(3.5f);
seen.ShouldNotContainKey(PressPath);
}
///
/// The legacy pre-3.0 STATE form is parsed on receive — so it must also be SUBSCRIBED, or that
/// tolerance is reachable only from a test calling HandleMessage directly.
///
[Fact]
public async Task EstablishAsync_SubscribesTheLegacyStateFilterToo()
{
var subscribe = new FakeSubscribeTransport();
var ing = NewIngestor(subscribe: subscribe, hostId: "otopcua-host-1");
await ing.EstablishAsync(TestContext.Current.CancellationToken);
subscribe.Filters.Select(f => f.Topic).ShouldContain("STATE/otopcua-host-1");
}
// =================================================================================
// Fixtures
// =================================================================================
private static RawTagEntry Tag(string rawPath, string tagConfig) => new(rawPath, tagConfig, WriteIdempotent: false);
private static string Blob(string group, string node, string? device, string metric, string? dataType)
{
var deviceKey = device is null ? "" : $"""{'"'}deviceId{'"'}:"{device}",""";
var typeKey = dataType is null ? "" : $""","dataType":"{dataType}" """;
return $$"""{"groupId":"{{group}}","edgeNodeId":"{{node}}",{{deviceKey}}"metricName":"{{metric}}"{{typeKey}}}""";
}
/// The standard authored set: three device metrics plus one node-level metric.
private static RawTagEntry[] DefaultTags() =>
[
Tag(TempPath, Blob(Group, Node, Device, "Temperature", "Float32")),
Tag(PressPath, Blob(Group, Node, Device, "Pressure", "Float32")),
Tag(CountPath, Blob(Group, Node, Device, "Count", "Int32")),
Tag(NodeUptimePath, Blob(Group, Node, null, "Uptime", "Int64")),
];
private static SparkplugIngestor NewIngestor(
RecordingPublishTransport? publish = null,
FakeSubscribeTransport? subscribe = null,
string hostId = "otopcua-host-1",
bool requestRebirthOnGap = true,
TimeSpan? rebirthDebounce = null,
int maxPayloadBytes = MqttSubscriptionManager.DefaultMaxPayloadBytes,
RawTagEntry[]? tags = null,
RawTagEntry[]? extraTags = null)
{
var options = new MqttDriverOptions
{
Mode = MqttMode.SparkplugB,
Sparkplug = new MqttSparkplugOptions
{
GroupId = Group,
HostId = hostId,
RequestRebirthOnGap = requestRebirthOnGap,
},
};
var ingestor = new SparkplugIngestor(
options,
"mqtt-spb-1",
subscribe,
publish,
logger: null,
cache: null,
maxPayloadBytes: maxPayloadBytes,
// Zero by default so a test asserting "these two triggers each produce an NCMD" is not
// silently satisfied by the production debounce; the debounce itself has its own tests.
rebirthDebounce: rebirthDebounce ?? TimeSpan.Zero);
ingestor.Register([.. tags ?? DefaultTags(), .. extraTags ?? []]);
return ingestor;
}
/// An ingestor with every default tag registered AND subscribed, so notifications fire.
private static async Task NewSubscribedIngestorAsync(
RecordingPublishTransport? publish = null,
FakeSubscribeTransport? subscribe = null,
bool requestRebirthOnGap = true,
TimeSpan? rebirthDebounce = null,
RawTagEntry[]? extraTags = null)
{
var ingestor = NewIngestor(
publish,
subscribe,
requestRebirthOnGap: requestRebirthOnGap,
rebirthDebounce: rebirthDebounce,
extraTags: extraTags);
await ingestor.SubscribeAsync(
[.. ingestor.AuthoredRawPaths],
TimeSpan.Zero,
TestContext.Current.CancellationToken);
return ingestor;
}
/// Feeds the standard NBIRTH (seq 0, bdSeq 1) + DBIRTH (seq 1) pair.
private static void FeedBirths(SparkplugIngestor ing)
{
ing.Dispatch(Topic(SparkplugMessageType.NBIRTH), Nbirth(seq: 0, bdSeq: 1));
ing.Dispatch(Topic(SparkplugMessageType.DBIRTH, Device), Dbirth(seq: 1));
}
private static SparkplugTopic Topic(SparkplugMessageType type, string? device = null) =>
new(type, Group, Node, device, null);
private static SparkplugPayload Decode(Payload payload) => SparkplugCodec.Decode(payload.ToByteArray());
/// The standard NBIRTH as WIRE BYTES — for the payload-ceiling tests, which need a real one.
private static byte[] NbirthBytes(ulong seq, ulong bdSeq)
{
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(BirthMetric("Uptime", 7UL, TahuDataType.Int64, 0L));
return payload.ToByteArray();
}
/// An NBIRTH: the bdSeq session token plus the node's own metric catalog.
private static SparkplugPayload Nbirth(ulong seq, ulong bdSeq)
{
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(BirthMetric("Uptime", 7UL, TahuDataType.Int64, 0L));
return Decode(payload);
}
/// The standard DBIRTH: Temperature@5 (Float), Pressure@6 (Float), Count@9 (Int32, -42).
private static SparkplugPayload Dbirth(ulong seq) => DbirthCustom(
seq,
("Temperature", TempAlias, TahuDataType.Float, 0f),
("Pressure", PressAlias, TahuDataType.Float, 0f),
("Count", CountAlias, TahuDataType.Int32, -42));
private static SparkplugPayload DbirthCustom(
ulong seq,
params (string Name, ulong Alias, TahuDataType Type, object Value)[] metrics)
{
var payload = new Payload { Seq = seq, Timestamp = 1721822400000UL };
foreach (var (name, alias, type, value) in metrics)
{
payload.Metrics.Add(BirthMetric(name, alias, type, value));
}
return Decode(payload);
}
/// A DDATA: metrics carry an alias and NOTHING else — the real post-birth wire shape.
private static SparkplugPayload Ddata(ulong seq, params (ulong Alias, TahuDataType Type, object Value)[] metrics)
=> DataPayload(seq, metrics);
/// An NDATA — identical shape to ; the topic is what makes it node-scoped.
private static SparkplugPayload Ndata(ulong seq, params (ulong Alias, TahuDataType Type, object Value)[] metrics)
=> DataPayload(seq, metrics);
private static SparkplugPayload DataPayload(
ulong seq,
(ulong Alias, TahuDataType Type, object Value)[] metrics)
{
var payload = new Payload { Seq = seq, Timestamp = 1721822405000UL };
foreach (var (alias, type, value) in metrics)
{
// Deliberately NO Name and NO Datatype: that is what a Sparkplug DATA metric looks like
// after a birth, and it is the case an "absent means empty string" consumer gets wrong.
var metric = new Payload.Types.Metric { Alias = alias };
SetWireValue(metric, type, value);
payload.Metrics.Add(metric);
}
return Decode(payload);
}
/// A DDEATH — published by the edge node, and therefore sequenced.
private static SparkplugPayload Ddeath(ulong seq) =>
Decode(new Payload { Seq = seq, Timestamp = 1721822405000UL });
/// An NDEATH — the broker-published Last Will: a bdSeq and NO seq at all.
private static SparkplugPayload Ndeath(ulong bdSeq)
{
var payload = new Payload { Timestamp = 1721822405000UL };
payload.Metrics.Add(new Payload.Types.Metric
{
Name = "bdSeq",
Datatype = (uint)TahuDataType.Uint64,
LongValue = bdSeq,
});
return Decode(payload);
}
private static Payload.Types.Metric BirthMetric(string name, ulong alias, TahuDataType type, object value)
{
var metric = new Payload.Types.Metric { Name = name, Alias = alias, Datatype = (uint)type };
SetWireValue(metric, type, value);
return metric;
}
///
/// Writes a value into the oneof field Sparkplug actually carries it in — including the
/// two's-complement-in-an-unsigned-field encoding for the signed integer types, which is the
/// whole reason exists.
///
private static void SetWireValue(Payload.Types.Metric metric, TahuDataType type, object value)
{
switch (type)
{
case TahuDataType.Boolean:
metric.BooleanValue = (bool)value;
break;
case TahuDataType.Float:
metric.FloatValue = (float)value;
break;
case TahuDataType.Double:
metric.DoubleValue = (double)value;
break;
case TahuDataType.Int8:
case TahuDataType.Int16:
case TahuDataType.Int32:
metric.IntValue = unchecked((uint)Convert.ToInt32(value));
break;
case TahuDataType.Uint8:
case TahuDataType.Uint16:
case TahuDataType.Uint32:
metric.IntValue = Convert.ToUInt32(value);
break;
case TahuDataType.Int64:
metric.LongValue = unchecked((ulong)Convert.ToInt64(value));
break;
case TahuDataType.Uint64:
case TahuDataType.DateTime:
metric.LongValue = Convert.ToUInt64(value);
break;
case TahuDataType.String:
case TahuDataType.Text:
case TahuDataType.Uuid:
metric.StringValue = (string)value;
break;
default:
throw new NotSupportedException($"Test helper does not encode {type}.");
}
}
/// Records every NCMD so the rebirth policy is assertable without a broker.
private sealed class RecordingPublishTransport : IMqttPublishTransport
{
public List Topics { get; } = [];
public List Payloads { get; } = [];
public Task PublishAsync(string topic, byte[] payload, int qos, bool retain, CancellationToken cancellationToken)
{
lock (Topics)
{
Topics.Add(topic);
Payloads.Add(payload);
}
// A retained NCMD replays to the edge node on every future subscribe, driving a permanent
// rebirth loop from one stale command. Assert it here so every rebirth path is covered.
retain.ShouldBeFalse();
return Task.CompletedTask;
}
}
/// Records every SUBSCRIBE so the group/STATE filters are assertable without a broker.
private sealed class FakeSubscribeTransport : IMqttSubscribeTransport
{
public List Filters { get; } = [];
/// Topics for which the fake SUBACK reports a rejection.
public Func Reject { get; set; } = _ => false;
public Task> SubscribeAsync(
IReadOnlyList filters,
CancellationToken cancellationToken)
{
Filters.AddRange(filters);
IReadOnlyList outcomes =
[
.. filters.Select(f => new MqttSubscribeOutcome(
f.Topic,
!Reject(f.Topic),
Reject(f.Topic) ? "NotAuthorized" : "GrantedQoS1")),
];
return Task.FromResult(outcomes);
}
}
}