using Org.Eclipse.Tahu.Protobuf; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Sparkplug; namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests; /// /// Covers — design doc §3.6 invariant #3. Two halves, each with a /// distinct failure mode the tests below pin from both directions: /// /// /// seq gap detection across the 0–255 wrap. Reading 255→0 as a gap /// rebirth-storms every edge node once per 256 messages; missing a real gap serves stale /// data at Good quality forever. Both directions are asserted. /// /// /// bdSeq birth↔death tie. A late Last-Will NDEATH from a previous session /// arriving after the new NBIRTH must not kill the freshly-born node. /// /// /// public sealed class SequenceTrackerTests { // --------------------------------------------------------------------------------------- // seq — the wrap boundary // --------------------------------------------------------------------------------------- /// /// The plan's headline case: 255 → 0 is the sequence continuing, not a gap. A tracker that /// reads the wrap as a gap requests a rebirth every 256 messages, and a busy edge node then /// spends its life republishing births instead of data. /// [Fact] public void Sequence_WrapAt255IsContiguous_NotAGap() { var s = new SequenceTracker(); s.Accept(254).ShouldBeTrue(); s.Accept(255).ShouldBeTrue(); s.Accept(0).ShouldBeTrue(); // wrap } /// /// The plan's mirror case: a skipped value is a genuine gap. Missing it means the driver /// silently keeps serving values it knows are behind the edge node's real state. /// [Fact] public void Sequence_SkippedValue_IsGap() { var s = new SequenceTracker(); s.Accept(10).ShouldBeTrue(); s.Accept(12).ShouldBeFalse(); // gap → caller requests rebirth } /// /// Two-and-a-bit full cycles of the 0–255 range report no gap at any point. The pair of /// single-step tests above can both pass on an off-by-one wrap that only misfires at one /// specific value; walking every value in the range leaves nowhere for that to hide. /// [Fact] public void Sequence_FullWrapCycles_NeverReportAGap() { var s = new SequenceTracker(); s.AcceptNodeBirth(0).ShouldBeTrue(); for (var i = 1; i <= 600; i++) { var seq = (ulong)(i % 256); s.Accept(seq).ShouldBeTrue($"seq {seq} (message {i}) was reported as a gap"); } } /// /// A gap is reported once and the tracker then resynchronizes onto the observed /// sequence. Leaving the baseline pinned at the pre-gap value would make every subsequent /// message report a gap too, turning one lost message into a permanent rebirth storm — the /// same pathology as a wrong wrap boundary, reached by a different route. /// [Fact] public void Sequence_GapReportedOnce_ThenResynchronizes() { var s = new SequenceTracker(); s.Accept(10).ShouldBeTrue(); s.Accept(13).ShouldBeFalse(); // three messages lost s.Accept(14).ShouldBeTrue(); // resynchronized onto 13 s.Accept(15).ShouldBeTrue(); } /// /// A repeated sequence number is not contiguous. Sparkplug publishes DATA at QoS 0, so a /// duplicate is not a broker redelivery — it is a publisher whose stream no longer matches /// what the driver can reason about, and a rebirth is the cheap correct answer. /// [Fact] public void Sequence_RepeatedValue_IsGap() { var s = new SequenceTracker(); s.Accept(10).ShouldBeTrue(); s.Accept(10).ShouldBeFalse(); } /// A sequence number going backwards is a gap, not a wrap. [Fact] public void Sequence_BackwardJump_IsGap() { var s = new SequenceTracker(); s.Accept(10).ShouldBeTrue(); s.Accept(9).ShouldBeFalse(); } /// /// The first message cannot be a gap — there is no baseline to measure it against — but the /// tracker must still report that it is not birth-synchronized, because a first /// message that was not a birth means the driver joined mid-stream and has no alias table. /// Conflating the two would let a late join look like a healthy synchronized node. /// [Fact] public void Sequence_FirstMessage_IsNotAGap_ButIsNotBirthSynchronized() { var s = new SequenceTracker(); s.IsBirthSynchronized.ShouldBeFalse(); s.LastSeq.ShouldBeNull(); s.Accept(137).ShouldBeTrue(); s.LastSeq.ShouldBe((byte)137); s.IsBirthSynchronized.ShouldBeFalse("a mid-stream first message is a late join, not a birth"); } /// /// The aliasing test. The codec hands over seq as a and /// deliberately declines to truncate it, so an out-of-range value reaches this type intact. A /// tracker that masks it into range turns 256 into a perfectly plausible 0 — and with the /// baseline sitting at 255, that is exactly the value the wrap expects next, so the bogus /// message would be accepted as contiguous. /// [Fact] public void Sequence_OutOfRangeSeq_IsRejected_AndDoesNotAliasOntoTheExpectedValue() { var s = new SequenceTracker(); s.Accept(255).ShouldBeTrue(); // 256 & 0xFF == 0, and 0 is precisely what follows 255. Masking would return true here. s.Accept(256).ShouldBeFalse(); } /// /// A rejected out-of-range value does not become the baseline either: the tracker keeps the /// last credible sequence number, so the following genuine message is still measured against /// something real. /// [Fact] public void Sequence_OutOfRangeSeq_DoesNotBecomeTheBaseline() { var s = new SequenceTracker(); s.Accept(10).ShouldBeTrue(); s.Accept(ulong.MaxValue).ShouldBeFalse(); s.LastSeq.ShouldBe((byte)10); s.Accept(11).ShouldBeTrue("the baseline must still be the last credible seq, 10"); } /// /// A message carrying no sequence number at all is a spec violation the tracker cannot place /// in the stream. It reports a gap (the safe direction — a rebirth resynchronizes everything) /// and leaves the baseline alone. /// [Fact] public void Sequence_AbsentSeq_IsGap_AndDoesNotAdvanceTheBaseline() { var s = new SequenceTracker(); s.Accept(10).ShouldBeTrue(); s.Accept(null).ShouldBeFalse(); s.LastSeq.ShouldBe((byte)10); s.Accept(11).ShouldBeTrue(); } // --------------------------------------------------------------------------------------- // seq — births restart the sequence // --------------------------------------------------------------------------------------- /// /// A birth restarts the sequence at 0, so it is never itself a gap however far the previous /// session had counted. A tracker that ran the birth through the ordinary contiguity check /// would flag a gap on the very message that just fixed everything, and request a rebirth in /// response to a birth. /// [Fact] public void Birth_RestartsTheSequence_SoABirthIsNeverAGap() { var s = new SequenceTracker(); s.Accept(200).ShouldBeTrue(); s.AcceptNodeBirth(0).ShouldBeTrue(); s.LastSeq.ShouldBe((byte)0); s.IsBirthSynchronized.ShouldBeTrue(); s.Accept(1).ShouldBeTrue(); } /// /// A gap detected after a birth is genuine — the birth resets the baseline, it does not /// suppress detection. Without this the previous test could be satisfied by a tracker that /// simply stopped checking once a birth arrived. /// [Fact] public void Birth_ThenSkippedValue_IsStillAGap() { var s = new SequenceTracker(); s.AcceptNodeBirth(0).ShouldBeTrue(); s.Accept(1).ShouldBeTrue(); s.Accept(3).ShouldBeFalse(); } /// /// A birth whose seq is in range but not the spec-required 0 is adopted as the /// baseline rather than rejected. Refusing it would leave the tracker unsynchronized against /// a publisher whose stream is otherwise perfectly followable, and every following message /// would demand another rebirth. /// [Fact] public void Birth_WithNonZeroInRangeSeq_IsAdoptedAsTheBaseline() { var s = new SequenceTracker(); s.AcceptNodeBirth(7).ShouldBeTrue(); s.LastSeq.ShouldBe((byte)7); s.IsBirthSynchronized.ShouldBeTrue(); s.Accept(8).ShouldBeTrue(); } /// /// A birth whose seq is out of range establishes no baseline — the same /// no-masking rule as the data path, applied where it matters most. /// [Fact] public void Birth_WithOutOfRangeSeq_EstablishesNoBaseline() { var s = new SequenceTracker(); s.AcceptNodeBirth(256).ShouldBeFalse(); s.LastSeq.ShouldBeNull(); s.IsBirthSynchronized.ShouldBeFalse(); } /// /// A DBIRTH continues the edge node's sequence — it does not restart it, and it must not be /// routed through . Only the NBIRTH resets /// seq, and only the NBIRTH/NDEATH carry bdSeq. Sending a DBIRTH to the node-birth /// entry point would rebase the sequence mid-stream and — having no bdSeq to pass /// — wipe the session token, at which point a stale Last Will finds nothing to be compared /// against and kills the live node. This test pins the shape at the tracker's own boundary so /// the ingest state machine has something to fail against if it wires the call sites the other /// way round. /// [Fact] public void DeviceBirth_ContinuesTheNodeSequence_AndLeavesTheSessionTokenAlone() { var s = new SequenceTracker(); s.AcceptNodeBirth(0, bdSeq: 7).ShouldBeTrue(); s.Accept(1).ShouldBeTrue(); // the DBIRTH — next in the node's stream, not a restart s.Accept(2).ShouldBeTrue(); // first DDATA s.BirthBdSeq.ShouldBe(7UL, "a device birth must not disturb the node's session token"); s.IsDeathForCurrentSession(6).ShouldBeFalse("the stale-will guard must still have a token to compare"); } /// /// returns the tracker to its virgin state. This is the /// reconnect seam: after a dropped connection the driver has missed an unknown number of /// messages, so the baseline it held is no longer evidence of anything. /// [Fact] public void Reset_ClearsTheBaselineAndTheBirthSynchronizedFlag() { var s = new SequenceTracker(); s.AcceptNodeBirth(0, bdSeq: 4).ShouldBeTrue(); s.Accept(1).ShouldBeTrue(); s.Reset(); s.LastSeq.ShouldBeNull(); s.IsBirthSynchronized.ShouldBeFalse(); s.BirthBdSeq.ShouldBeNull(); s.Accept(99).ShouldBeTrue("a virgin tracker cannot detect a gap on its first message"); } // --------------------------------------------------------------------------------------- // bdSeq — the birth↔death tie // --------------------------------------------------------------------------------------- /// /// The case the tie exists for. A node's connection drops, it reconnects and publishes /// a fresh NBIRTH — and only then does the broker notice the old connection died and deliver /// its Last Will NDEATH, carrying the previous session's bdSeq. Without the compare, /// that stale will marks a live, freshly-born node dead and every one of its tags goes Bad. /// [Fact] public void Death_WithStaleBdSeqArrivingAfterARebirth_DoesNotApply() { var s = new SequenceTracker(); s.AcceptNodeBirth(0, bdSeq: 7); // session 7 — the one that is about to die s.AcceptNodeBirth(0, bdSeq: 8); // session 8 — the node is back, and alive s.IsDeathForCurrentSession(7).ShouldBeFalse("session 7's will must not kill session 8"); } /// A death whose bdSeq matches the current birth is the real thing and applies. [Fact] public void Death_WithMatchingBdSeq_Applies() { var s = new SequenceTracker(); s.AcceptNodeBirth(0, bdSeq: 8); s.IsDeathForCurrentSession(8).ShouldBeTrue(); } /// /// A death arriving before any birth was ever seen applies. The tracker has nothing to /// disprove it with, and the two failure directions are not symmetric: acting on a death /// wrongly marks tags stale until the next birth restores them (§3.6 invariant #4), while /// ignoring a real death leaves a dead node's last values flowing at Good quality forever. /// [Fact] public void Death_BeforeAnyBirth_Applies() { var s = new SequenceTracker(); s.IsDeathForCurrentSession(7).ShouldBeTrue(); } /// /// A death carrying no bdSeq metric cannot be tied to anything, so it applies — same /// fail-toward-stale reasoning as above. /// [Fact] public void Death_WithNoBdSeq_Applies() { var s = new SequenceTracker(); s.AcceptNodeBirth(0, bdSeq: 8); s.IsDeathForCurrentSession(null).ShouldBeTrue(); } /// A death applies when the birth was the one that carried no bdSeq. [Fact] public void Death_WhenTheBirthCarriedNoBdSeq_Applies() { var s = new SequenceTracker(); s.AcceptNodeBirth(0, bdSeq: null); s.IsDeathForCurrentSession(7).ShouldBeTrue(); } /// /// A birth records its bdSeq even when its seq was unusable. Session identity and /// sequence position are independent facts; discarding the tie because the sequence number /// was garbled would leave the next death unmatchable. /// [Fact] public void Birth_WithUnusableSeq_StillRecordsItsBdSeq() { var s = new SequenceTracker(); s.AcceptNodeBirth(256, bdSeq: 8).ShouldBeFalse(); s.BirthBdSeq.ShouldBe(8UL); s.IsDeathForCurrentSession(7).ShouldBeFalse(); } // --------------------------------------------------------------------------------------- // bdSeq — reading it out of a payload // --------------------------------------------------------------------------------------- /// /// bdSeq is not a top-level payload field: it rides as a metric named bdSeq in /// the NBIRTH and the NDEATH. /// [Fact] public void TryReadBdSeq_ReadsTheBdSeqMetric() { var payload = PayloadWith(Metric("bdSeq", DataType.Int64, 8UL)); SequenceTracker.TryReadBdSeq(payload, out var bdSeq).ShouldBeTrue(); bdSeq.ShouldBe(8UL); } /// /// Sparkplug carries Int64 as two's complement in an unsigned field, so a negative /// bdSeq reaches the projection as an enormous . Reading it raw would /// mint a plausible-looking session token out of nonsense; the reinterpretation is what /// exposes it as negative so it can be refused. /// [Fact] public void TryReadBdSeq_NegativeSignedValue_IsRefused_NotReadAsAHugeToken() { var payload = PayloadWith(Metric("bdSeq", DataType.Int64, unchecked((ulong)-1L))); SequenceTracker.TryReadBdSeq(payload, out var bdSeq).ShouldBeFalse(); bdSeq.ShouldBe(0UL); } /// /// An unsigned bdSeq is read as-is — no reinterpretation applies. (The generated enum member /// is Uint64, not UInt64: protobuf's C# codegen lowercases the interior of the /// proto's UInt64.) /// [Fact] public void TryReadBdSeq_UnsignedValue_IsReadAsIs() { var payload = PayloadWith(Metric("bdSeq", DataType.Uint64, 9UL)); SequenceTracker.TryReadBdSeq(payload, out var bdSeq).ShouldBeTrue(); bdSeq.ShouldBe(9UL); } /// A payload that carries no bdSeq metric reports absence rather than a default. [Fact] public void TryReadBdSeq_PayloadWithoutTheMetric_ReportsAbsence() { var payload = PayloadWith(Metric("Temperature", DataType.Float, 21.5f)); SequenceTracker.TryReadBdSeq(payload, out var bdSeq).ShouldBeFalse(); bdSeq.ShouldBe(0UL); } /// /// An undecodable payload yields no bdSeq. It presents as metric-less exactly like a valid /// but empty one, and only tells them apart. /// [Fact] public void TryReadBdSeq_InvalidPayload_ReportsAbsence() { SequenceTracker.TryReadBdSeq(SparkplugPayload.Invalid, out _).ShouldBeFalse(); } /// A null payload is a verdict, not an exception — this runs on the dispatcher thread. [Fact] public void TryReadBdSeq_NullPayload_ReportsAbsence_DoesNotThrow() { Should.NotThrow(() => SequenceTracker.TryReadBdSeq(null, out _).ShouldBeFalse()); } /// /// A bdSeq metric with no scalar value (explicitly null, or a kind v1 does not support) is /// not a session token. /// [Fact] public void TryReadBdSeq_NonScalarMetric_ReportsAbsence() { var payload = PayloadWith(new SparkplugMetric("bdSeq", null, DataType.Int64, null, SparkplugValueKind.Null, null)); SequenceTracker.TryReadBdSeq(payload, out _).ShouldBeFalse(); } private static SparkplugPayload PayloadWith(params SparkplugMetric[] metrics) => new(IsValid: true, Seq: 0UL, TimestampMs: 1UL, Metrics: metrics); private static SparkplugMetric Metric(string name, DataType dataType, object value) => new(name, Alias: null, dataType, TimestampMs: null, SparkplugValueKind.Scalar, value); }