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; /// /// The two push-model edge behaviours lacked (archreview /// 05/STAB-18), matching on the plain path: an /// initial push of the cached snapshot on every (re)subscribe, and a /// connection-loss stale to BadNoCommunication with the value retained. /// /// Where the falsifier actually is on this seam — measured, not assumed. Recovery is /// proved with an identical DBIRTH value, as on the plain path. But Sparkplug ingestion /// carries no unchanged-value suppression (it publishes births and data directly rather /// than through MqttSubscriptionManager.Deliver), so that replay was already raised Good /// before this change — pinned below by /// . The /// load-bearing assertion here is therefore the stale: a Sparkplug tag now reports Bad on /// the driver's own disconnect edge instead of Good over a dead broker. /// /// A death is not a connection loss. DDEATH/NDEATH is the source announcing it is /// offline and keeps its own BadCommunicationError stale, unchanged; ConnectionLost /// is this driver's transport losing the broker and reports BadNoCommunication — the same /// code the host's comms-loss fan-out writes. The DDEATH regression case below pins that the two /// paths stayed distinct. /// public sealed class SparkplugIngestorRecoveryTests { // 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 BadNoCommunication = 0x80310000u; private const uint BadWaitingForInitialData = 0x80320000u; 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 ulong TempAlias = 5UL; private const ulong PressAlias = 6UL; private sealed record Raised(string Reference, object? Value, uint StatusCode); private static List Record(SparkplugIngestor ing) { var raised = new List(); ing.OnDataChange += (_, e) => raised.Add(new Raised(e.FullReference, e.Snapshot.Value, e.Snapshot.StatusCode)); return raised; } // --------------------------------------------------------------------------------- // (A) Initial push on (re)subscribe // --------------------------------------------------------------------------------- /// /// A metric observed before this subscription existed is pushed once from the cache. This is the /// address-space-rebuild shape: a rebuild reseeds every node BadWaitingForInitialData and /// triggers no rebirth, so without the push a quiet metric never refills. /// [Fact] public async Task SubscribeAsync_pushes_the_cached_snapshot_for_an_observed_metric() { var ing = await NewSubscribedIngestorAsync(); FeedBirths(ing); ing.Dispatch(Topic(SparkplugMessageType.DDATA, Device), Ddata(seq: 2, (TempAlias, TahuDataType.Float, 7f))); var raised = Record(ing); await ing.SubscribeAsync([TempPath], TimeSpan.Zero, TestContext.Current.CancellationToken); raised.Count.ShouldBe(1); raised[0].Reference.ShouldBe(TempPath); raised[0].Value.ShouldBe(7f); raised[0].StatusCode.ShouldBe(Good); } /// /// A metric that has never been observed carries only the cache's /// BadWaitingForInitialData placeholder, and pushing that would claim an observation the /// driver has not made. /// [Fact] public async Task SubscribeAsync_does_not_push_a_never_observed_metric() { var ing = await NewSubscribedIngestorAsync(); var raised = Record(ing); await ing.SubscribeAsync([TempPath, PressPath], TimeSpan.Zero, TestContext.Current.CancellationToken); raised.ShouldBeEmpty(); ing.Values.Read(TempPath).StatusCode.ShouldBe(BadWaitingForInitialData); } // --------------------------------------------------------------------------------- // (B) Connection-loss stale + the identical-value recovery // --------------------------------------------------------------------------------- /// /// A lost broker session marks every observed metric BadNoCommunication with its last /// value retained. ResetSessionState's previous remark argued the opposite — that the /// connection's health surface already reports the outage — but health is not quality, and an /// OPC UA client subscribed to the metric's node sees neither the health page nor the outage. /// [Fact] public async Task OnConnectionLost_stales_every_observed_metric_keeping_its_value() { var ing = await NewSubscribedIngestorAsync(); FeedBirths(ing); ing.Dispatch(Topic(SparkplugMessageType.DDATA, Device), Ddata(seq: 2, (TempAlias, TahuDataType.Float, 7f))); var raised = Record(ing); ing.OnConnectionLost(); raised.ShouldContain(r => r.Reference == TempPath && r.StatusCode == BadNoCommunication && Equals(r.Value, 7f)); ing.Values.Read(TempPath).StatusCode.ShouldBe(BadNoCommunication); ing.Values.Read(TempPath).Value.ShouldBe(7f); } /// /// The contract test for this seam. The reconnect replays NBIRTH/DBIRTH carrying the /// identical metric value, and it must be raised Good. Before the stale, the birth's /// value equalled the cached one and the whole tag stayed Bad until the plant moved. /// [Fact] public async Task After_the_stale_an_identical_birth_replay_is_raised_Good() { var ing = await NewSubscribedIngestorAsync(); FeedBirths(ing, tempBirthValue: 7f); ing.OnConnectionLost(); ing.Values.Read(TempPath).StatusCode.ShouldBe(BadNoCommunication); var raised = Record(ing); FeedBirths(ing, tempBirthValue: 7f, bdSeq: 2); // the reconnect's replayed births, value unchanged raised.ShouldContain(r => r.Reference == TempPath && r.StatusCode == Good && Equals(r.Value, 7f)); } /// /// Vacuity control, and it changes what the test above proves. Unlike the plain path, /// Sparkplug ingestion has no unchanged-value suppression at all — a birth republishes its /// metrics regardless — so an identical birth replay would have been raised Good even before /// the stale existed. This asserts that directly. The consequence: on THIS seam the /// load-bearing assertion is the stale itself (a Sparkplug tag now reports Bad on the driver's /// own disconnect edge instead of Good over a dead broker), and the replay test guards only /// that the stale is not sticky. /// [Fact] public async Task An_identical_birth_replay_is_raised_even_without_a_preceding_stale() { var ing = await NewSubscribedIngestorAsync(); FeedBirths(ing, tempBirthValue: 7f); var raised = Record(ing); FeedBirths(ing, tempBirthValue: 7f, bdSeq: 2); raised.ShouldContain(r => r.Reference == TempPath && r.StatusCode == Good && Equals(r.Value, 7f)); } /// /// Regression: a DDEATH still stales with BadCommunicationError, the source-announced /// code. The connection-loss stale is a second, distinct path — it did not replace this one. /// [Fact] public async Task DDeath_still_stales_with_BadCommunicationError() { var ing = await NewSubscribedIngestorAsync(); FeedBirths(ing, tempBirthValue: 7f); var raised = Record(ing); ing.Dispatch(Topic(SparkplugMessageType.DDEATH, Device), Ddeath(seq: 2)); raised.ShouldContain(r => r.Reference == TempPath && r.StatusCode == BadCommunicationError); ing.Values.Read(TempPath).StatusCode.ShouldBe(BadCommunicationError); } // ================================================================================= // Fixtures — deliberately a local copy of SparkplugIngestorTests' helpers (both are // private to their own class; sharing them would make one suite's edits ripple into // the other's 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}","""; return $$"""{"groupId":"{{group}}","edgeNodeId":"{{node}}",{{deviceKey}}"metricName":"{{metric}}","dataType":"{{dataType}}"}"""; } private static async Task NewSubscribedIngestorAsync() { var options = new MqttDriverOptions { Mode = MqttMode.SparkplugB, Sparkplug = new MqttSparkplugOptions { GroupId = Group, HostId = "otopcua-host-1" }, }; var ingestor = new SparkplugIngestor(options, "mqtt-spb-1", rebirthDebounce: TimeSpan.Zero); ingestor.Register( [ Tag(TempPath, Blob(Group, Node, Device, "Temperature", "Float32")), Tag(PressPath, Blob(Group, Node, Device, "Pressure", "Float32")), ]); await ingestor.SubscribeAsync([.. ingestor.AuthoredRawPaths], TimeSpan.Zero, TestContext.Current.CancellationToken); return ingestor; } private static void FeedBirths(SparkplugIngestor ing, float tempBirthValue = 0f, ulong bdSeq = 1) { ing.Dispatch(Topic(SparkplugMessageType.NBIRTH), Nbirth(seq: 0, bdSeq)); ing.Dispatch(Topic(SparkplugMessageType.DBIRTH, Device), Dbirth(seq: 1, tempBirthValue)); } 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()); 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, }); return Decode(payload); } private static SparkplugPayload Dbirth(ulong seq, float tempValue) { var payload = new Payload { Seq = seq, Timestamp = 1721822400000UL }; payload.Metrics.Add(BirthMetric("Temperature", TempAlias, tempValue)); payload.Metrics.Add(BirthMetric("Pressure", PressAlias, 0f)); return Decode(payload); } private static SparkplugPayload Ddata(ulong seq, params (ulong Alias, TahuDataType Type, float Value)[] metrics) { var payload = new Payload { Seq = seq, Timestamp = 1721822405000UL }; foreach (var (alias, _, value) in metrics) { // Deliberately NO Name and NO Datatype: the real post-birth wire shape. payload.Metrics.Add(new Payload.Types.Metric { Alias = alias, FloatValue = value }); } return Decode(payload); } private static SparkplugPayload Ddeath(ulong seq) => Decode(new Payload { Seq = seq, Timestamp = 1721822405000UL }); private static Payload.Types.Metric BirthMetric(string name, ulong alias, float value) => new() { Name = name, Alias = alias, Datatype = (uint)TahuDataType.Float, FloatValue = value, }; }