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.AcceptBirth(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.AcceptBirth(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.AcceptBirth(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.AcceptBirth(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.AcceptBirth(256).ShouldBeFalse();
s.LastSeq.ShouldBeNull();
s.IsBirthSynchronized.ShouldBeFalse();
}
///
/// 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.AcceptBirth(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.AcceptBirth(0, bdSeq: 7); // session 7 — the one that is about to die
s.AcceptBirth(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.AcceptBirth(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.AcceptBirth(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.AcceptBirth(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.AcceptBirth(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);
}