using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Sparkplug;
// See SparkplugTopicTests.cs for why this alias is redeclared locally in every consuming project
// rather than inherited: `SparkplugDataType` is a `global using` alias for the generated
// `Org.Eclipse.Tahu.Protobuf.DataType` (declared in Contracts/SparkplugDataType.cs), and `global using`
// scope does not cross a ProjectReference.
using SparkplugDataType = Org.Eclipse.Tahu.Protobuf.DataType;
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests;
///
/// Covers + (Task 18) — design doc §3.6
/// invariants #1 and #2.
///
///
///
/// What is actually being pinned here. Sparkplug's alias mechanism exists to shrink DATA
/// payloads: a BIRTH declares name ↔ alias and every subsequent DATA metric carries only
/// the alias. The alias is scoped to one birth — after a rebirth an edge node is free to
/// assign alias 5 to an entirely different metric. If this driver merged alias tables across
/// births, or bound authored tags to aliases rather than to the stable metric name, a rebirth
/// would silently start routing Pressure readings into a Temperature tag: good quality,
/// plausible values, completely wrong. It is invisible without a test that specifically
/// reuses an alias across a rebirth, which is what
/// and
/// do.
///
///
/// The cross-scope half of the same hazard is that a device's alias table can outlive the birth
/// it came from when its edge node re-births — pinned by
/// .
///
///
public sealed class AliasBindingTests
{
// ---- AliasTable: the two load-bearing invariants ----
///
/// §3.6 invariant #1/#2, the headline case: a rebirth may point the same alias at a different
/// metric, and the table must report the new metric — i.e. the birth replaced the map
/// rather than merging into it.
///
[Fact]
public void Rebirth_ReusesAliasForDifferentMetric_ResolvesByName_NotAlias()
{
var table = new AliasTable();
table.RebuildFromBirth([Birth("Temperature", 5, SparkplugDataType.Float)]);
table.Resolve(5)!.Name.ShouldBe("Temperature");
// Rebirth: alias 5 now means a DIFFERENT metric.
table.RebuildFromBirth([Birth("Pressure", 5, SparkplugDataType.Float)]);
table.Resolve(5)!.Name.ShouldBe("Pressure"); // wholesale replace, not merge
table.TryResolveByName("Temperature", out _).ShouldBeFalse();
}
///
/// §3.6 invariant #2: an alias the new birth did not declare must be gone, not carried
/// forward. A merge would leave alias 1 resolving to a metric the edge node no longer publishes.
///
[Fact]
public void RebuildFromBirth_DoesNotMergeStaleAliases()
{
var table = new AliasTable();
table.RebuildFromBirth([Birth("A", 1, SparkplugDataType.Int32)]);
table.RebuildFromBirth([Birth("B", 2, SparkplugDataType.Int32)]);
table.TryResolve(1, out _).ShouldBeFalse(); // alias 1 gone after rebirth
table.TryResolve(2, out _).ShouldBeTrue();
table.Count.ShouldBe(1);
}
///
/// The degenerate replace path: a birth carrying no usable metric still clears the table.
/// An implementation that short-circuits on an empty input (or merges) keeps every stale alias
/// alive behind a birth that declared none of them.
///
[Fact]
public void RebuildFromBirth_EmptyBirth_ClearsEveryAlias()
{
var table = new AliasTable();
table.RebuildFromBirth([Birth("A", 1, SparkplugDataType.Int32), Birth("B", 2, SparkplugDataType.Int32)]);
table.RebuildFromBirth([]);
table.Count.ShouldBe(0);
table.TryResolve(1, out _).ShouldBeFalse();
table.TryResolve(2, out _).ShouldBeFalse();
table.Metrics.ShouldBeEmpty();
}
///
/// The mirror of the headline case: the metric stays put and its alias moves. Binding is
/// by name, so the tag keeps resolving; only the per-birth alias cache changed.
///
[Fact]
public void Rebirth_MovesAMetricToADifferentAlias_NameBindingFollowsIt()
{
var table = new AliasTable();
table.RebuildFromBirth([Birth("Temperature", 5, SparkplugDataType.Float)]);
table.RebuildFromBirth([Birth("Temperature", 9, SparkplugDataType.Float)]);
table.TryResolveByName("Temperature", out var byName).ShouldBeTrue();
byName!.Alias.ShouldBe(9ul);
table.Resolve(9)!.Name.ShouldBe("Temperature");
table.TryResolve(5, out _).ShouldBeFalse();
}
// ---- AliasTable: malformed births ----
///
/// A BIRTH metric carrying an alias but no name is malformed and is rejected, never
/// stored alias-only: the name is the binding key, so an alias-only entry could only ever
/// resolve to a binding no authored tag can match — or, worse, match a tag whose authored
/// metric name happens to be blank.
///
[Fact]
public void BirthMetricWithAnAliasButNoName_IsRejected_NotStoredAliasOnly()
{
var table = new AliasTable();
var result = table.RebuildFromBirth([Birth(null, 5, SparkplugDataType.Float), Birth(" ", 6, SparkplugDataType.Float)]);
result.Accepted.ShouldBe(0);
result.RejectedUnnamed.ShouldBe(2);
result.HasAnomaly.ShouldBeTrue();
table.TryResolve(5, out _).ShouldBeFalse();
table.TryResolve(6, out _).ShouldBeFalse();
}
///
/// Aliases are optional in Sparkplug — a publisher may declare a metric by name alone and then
/// send DATA carrying the name. Such a metric is a legitimate binding; it simply never appears
/// in the alias index.
///
[Fact]
public void BirthMetricWithNoAlias_IsBindableByName_AndAbsentFromTheAliasIndex()
{
var table = new AliasTable();
table.RebuildFromBirth([Birth("Temperature", null, SparkplugDataType.Float)]);
table.TryResolveByName("Temperature", out var binding).ShouldBeTrue();
binding!.Alias.ShouldBeNull();
table.Metrics.Count.ShouldBe(1);
}
///
/// Two metrics claiming the same alias in one birth is malformed, and any resolution is a coin
/// flip between two different metrics — precisely the mis-route this whole type exists to
/// prevent. The colliding alias is dropped from the alias index (the consumer sees an unknown
/// alias and can request a rebirth) while both metrics stay bindable by their names.
///
[Fact]
public void DuplicateAliasInOneBirth_IsUnresolvable_RatherThanGuessed()
{
var table = new AliasTable();
var result = table.RebuildFromBirth(
[Birth("Temperature", 5, SparkplugDataType.Float), Birth("Pressure", 5, SparkplugDataType.Float)]);
result.AliasCollisions.ShouldBe(1);
table.TryResolve(5, out _).ShouldBeFalse();
table.TryResolveByName("Temperature", out _).ShouldBeTrue();
table.TryResolveByName("Pressure", out _).ShouldBeTrue();
}
///
/// Two metrics sharing a name in one birth: last-wins (the name is the binding key, so evicting
/// it would take the tag dark entirely), reported so the consumer can warn.
///
[Fact]
public void DuplicateNameInOneBirth_IsLastWins_AndReported()
{
var table = new AliasTable();
var result = table.RebuildFromBirth(
[Birth("Temperature", 5, SparkplugDataType.Float), Birth("Temperature", 6, SparkplugDataType.Double)]);
result.DuplicateNames.ShouldBe(1);
result.Accepted.ShouldBe(1);
table.TryResolveByName("Temperature", out var binding).ShouldBeTrue();
binding!.Alias.ShouldBe(6ul);
}
///
/// A BIRTH metric with no datatype is malformed, but it is kept as
/// rather than rejected: rejecting it makes every DATA
/// message for its alias an unknown-alias event, which the ingest state machine answers with a
/// rebirth request — and the edge node would answer with the same malformed birth, forever. Kept
/// as Unknown, the typed layer refuses the value once (ToDriverDataType() returns null)
/// and nothing storms.
///
[Fact]
public void BirthMetricWithNoDatatype_IsKeptAsUnknown_NotRejected()
{
var table = new AliasTable();
var result = table.RebuildFromBirth([Untyped("Temperature", 5)]);
result.Accepted.ShouldBe(1);
result.RejectedUnnamed.ShouldBe(0);
table.Resolve(5)!.DataType.ShouldBe(SparkplugDataType.Unknown);
}
///
/// The birth datatype is what makes Sparkplug's two's-complement-in-an-unsigned-field encoding
/// undoable: a DATA metric carries no datatype of its own, so the binding is the only place the
/// signedness is known. Without this, an Int32 metric holding -42 publishes as 4294967254 —
/// plausible, Good quality, invisible.
///
[Fact]
public void Binding_Reinterpret_UndoesTwosComplement_UsingTheBirthDatatype()
{
var table = new AliasTable();
table.RebuildFromBirth([Birth("Setpoint", 5, SparkplugDataType.Int32)]);
// What a DDATA metric for alias 5 carries: the raw unsigned wire field, no datatype.
var raw = unchecked((uint)-42);
table.Resolve(5)!.Reinterpret(raw).ShouldBe(-42);
}
/// A null birth sequence is a caller bug, not a birth — it must not silently clear the table.
[Fact]
public void RebuildFromBirth_Null_Throws() =>
Should.Throw(() => new AliasTable().RebuildFromBirth(null!));
// ---- BirthCache: scoping ----
///
/// Sparkplug spec: an NBIRTH means the edge node (re)started, so every DBIRTH previously
/// published under it is invalid until re-published. Keeping a device's alias table across its
/// node's rebirth is the same stale-alias mis-route as invariant #2, one scope up.
///
[Fact]
public void NodeBirth_InvalidatesEveryDeviceUnderThatNode()
{
var cache = new BirthCache();
cache.ApplyDeviceBirth("Plant1", "EdgeA", "Filler1", [Birth("Temperature", 5, SparkplugDataType.Float)]);
cache.ApplyDeviceBirth("Plant1", "EdgeA", "Filler2", [Birth("Speed", 7, SparkplugDataType.Float)]);
var outcome = cache.ApplyNodeBirth("Plant1", "EdgeA", [Birth("Node Control/Rebirth", 1, SparkplugDataType.Boolean)]);
cache.Find(new SparkplugScope("Plant1", "EdgeA", "Filler1")).ShouldBeNull();
cache.Find(new SparkplugScope("Plant1", "EdgeA", "Filler2")).ShouldBeNull();
cache.Find(new SparkplugScope("Plant1", "EdgeA", null)).ShouldNotBeNull();
// The invalidated devices come back with their metric sets so the consumer can fan STALE out
// without a lookup against a table that has already been evicted.
outcome.InvalidatedDevices.Count.ShouldBe(2);
outcome.InvalidatedDevices.SelectMany(d => d.Metrics).Select(m => m.Name)
.ShouldBe(["Temperature", "Speed"], ignoreOrder: true);
}
/// An NBIRTH is scoped to its own edge node — another node's devices are untouched.
[Fact]
public void NodeBirth_DoesNotTouchAnotherNodesDevices()
{
var cache = new BirthCache();
cache.ApplyDeviceBirth("Plant1", "EdgeB", "Filler1", [Birth("Temperature", 5, SparkplugDataType.Float)]);
var outcome = cache.ApplyNodeBirth("Plant1", "EdgeA", [Birth("Uptime", 1, SparkplugDataType.Int64)]);
outcome.InvalidatedDevices.ShouldBeEmpty();
cache.Find(new SparkplugScope("Plant1", "EdgeB", "Filler1")).ShouldNotBeNull();
}
/// A DBIRTH replaces exactly one device's table; its siblings and its node keep theirs.
[Fact]
public void DeviceBirth_ReplacesOnlyThatDevicesTable()
{
var cache = new BirthCache();
cache.ApplyNodeBirth("Plant1", "EdgeA", [Birth("Uptime", 1, SparkplugDataType.Int64)]);
cache.ApplyDeviceBirth("Plant1", "EdgeA", "Filler1", [Birth("Temperature", 5, SparkplugDataType.Float)]);
cache.ApplyDeviceBirth("Plant1", "EdgeA", "Filler2", [Birth("Speed", 5, SparkplugDataType.Float)]);
cache.ApplyDeviceBirth("Plant1", "EdgeA", "Filler1", [Birth("Humidity", 5, SparkplugDataType.Float)]);
cache.Find(new SparkplugScope("Plant1", "EdgeA", "Filler1"))!.Resolve(5)!.Name.ShouldBe("Humidity");
cache.Find(new SparkplugScope("Plant1", "EdgeA", "Filler2"))!.Resolve(5)!.Name.ShouldBe("Speed");
cache.Find(new SparkplugScope("Plant1", "EdgeA", null))!.Resolve(1)!.Name.ShouldBe("Uptime");
}
///
/// Key completeness. A cache keyed on the device id alone — or on node/device without the
/// group — would let one plant's Filler1 answer for another's, which is the same
/// wrong-but-plausible mis-route as an alias collision.
///
[Fact]
public void Scopes_AreDistinctAcrossGroupsAndNodesWithTheSameDeviceId()
{
var cache = new BirthCache();
cache.ApplyDeviceBirth("Plant1", "EdgeA", "Filler1", [Birth("Temperature", 5, SparkplugDataType.Float)]);
cache.ApplyDeviceBirth("Plant2", "EdgeA", "Filler1", [Birth("Pressure", 5, SparkplugDataType.Float)]);
cache.ApplyDeviceBirth("Plant1", "EdgeB", "Filler1", [Birth("Speed", 5, SparkplugDataType.Float)]);
cache.Count.ShouldBe(3);
cache.Find(new SparkplugScope("Plant1", "EdgeA", "Filler1"))!.Resolve(5)!.Name.ShouldBe("Temperature");
cache.Find(new SparkplugScope("Plant2", "EdgeA", "Filler1"))!.Resolve(5)!.Name.ShouldBe("Pressure");
cache.Find(new SparkplugScope("Plant1", "EdgeB", "Filler1"))!.Resolve(5)!.Name.ShouldBe("Speed");
}
///
/// §3.6 invariant #4's data half: an NDEATH takes the node and everything under it out of the
/// cache and hands back the metric sets, which is what the consumer fans STALE out over.
///
[Fact]
public void ForgetNode_RemovesNodeAndDevices_AndReturnsTheirMetricsForStaleFanOut()
{
var cache = new BirthCache();
cache.ApplyNodeBirth("Plant1", "EdgeA", [Birth("Uptime", 1, SparkplugDataType.Int64)]);
cache.ApplyDeviceBirth("Plant1", "EdgeA", "Filler1", [Birth("Temperature", 5, SparkplugDataType.Float)]);
cache.ApplyDeviceBirth("Plant1", "EdgeB", "Filler1", [Birth("Speed", 5, SparkplugDataType.Float)]);
var removed = cache.ForgetNode("Plant1", "EdgeA");
removed.SelectMany(r => r.Metrics).Select(m => m.Name).ShouldBe(["Uptime", "Temperature"], ignoreOrder: true);
cache.Find(new SparkplugScope("Plant1", "EdgeA", null)).ShouldBeNull();
cache.Find(new SparkplugScope("Plant1", "EdgeA", "Filler1")).ShouldBeNull();
cache.Find(new SparkplugScope("Plant1", "EdgeB", "Filler1")).ShouldNotBeNull();
}
/// A DDEATH takes out exactly one device and leaves its node's own metrics live.
[Fact]
public void TryForgetDevice_RemovesOnlyThatDevice_AndReturnsItsMetrics()
{
var cache = new BirthCache();
cache.ApplyNodeBirth("Plant1", "EdgeA", [Birth("Uptime", 1, SparkplugDataType.Int64)]);
cache.ApplyDeviceBirth("Plant1", "EdgeA", "Filler1", [Birth("Temperature", 5, SparkplugDataType.Float)]);
cache.TryForgetDevice("Plant1", "EdgeA", "Filler1", out var removed).ShouldBeTrue();
removed.Metrics.Select(m => m.Name).ShouldBe(["Temperature"]);
removed.Scope.DeviceId.ShouldBe("Filler1");
cache.Find(new SparkplugScope("Plant1", "EdgeA", "Filler1")).ShouldBeNull();
cache.Find(new SparkplugScope("Plant1", "EdgeA", null)).ShouldNotBeNull();
cache.TryForgetDevice("Plant1", "EdgeA", "Filler1", out _).ShouldBeFalse();
}
///
/// After a death the scope is absent, not merely empty — that is what makes DATA arriving
/// before the next birth a data-before-birth event (⇒ rebirth request) rather than a silent
/// unknown-alias drop against a table that still looks alive.
///
[Fact]
public void Find_AfterDeath_IsNull_SoDataBeforeRebirthIsNotResolvable()
{
var cache = new BirthCache();
cache.ApplyDeviceBirth("Plant1", "EdgeA", "Filler1", [Birth("Temperature", 5, SparkplugDataType.Float)]);
cache.TryForgetDevice("Plant1", "EdgeA", "Filler1", out _).ShouldBeTrue();
cache.Find(new SparkplugScope("Plant1", "EdgeA", "Filler1")).ShouldBeNull();
cache.Count.ShouldBe(0);
}
///
/// Invariant #1 end-to-end through the cache: the alias-reuse mis-route must not sneak back in
/// through the scoped path the ingest state machine actually calls.
///
[Fact]
public void DeviceRebirth_ReusingAnAliasForADifferentMetric_ResolvesByName()
{
var cache = new BirthCache();
var scope = new SparkplugScope("Plant1", "EdgeA", "Filler1");
cache.ApplyDeviceBirth("Plant1", "EdgeA", "Filler1", [Birth("Temperature", 5, SparkplugDataType.Float)]);
cache.ApplyDeviceBirth("Plant1", "EdgeA", "Filler1", [Birth("Pressure", 5, SparkplugDataType.Float)]);
cache.Find(scope)!.Resolve(5)!.Name.ShouldBe("Pressure");
cache.Find(scope)!.TryResolveByName("Temperature", out _).ShouldBeFalse();
}
/// Builds a BIRTH metric the way projects one.
private static SparkplugMetric Birth(string? name, ulong? alias, SparkplugDataType dataType, object? value = null) =>
new(
Name: name,
Alias: alias,
DataType: dataType,
TimestampMs: null,
ValueKind: value is null ? SparkplugValueKind.Absent : SparkplugValueKind.Scalar,
Value: value);
/// Builds a malformed BIRTH metric that declares no datatype at all.
private static SparkplugMetric Untyped(string name, ulong alias) =>
new(Name: name, Alias: alias, DataType: null, TimestampMs: null, ValueKind: SparkplugValueKind.Absent, Value: null);
}