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); }