using Org.Eclipse.Tahu.Protobuf; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Sparkplug; using TahuDataType = Org.Eclipse.Tahu.Protobuf.DataType; namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests; /// /// Covers (Task 20) — the NCMD encode + bounded publish seam that /// Task 21 (ingest gap/unknown-alias/data-before-birth detection) and Task 23 (the browser's /// operator-triggered scoped rebirth) both call into. /// public sealed class RebirthRequesterTests { // ----------------------------------------------------------------------------------------- // Build — pure encode. The plan's own test, verbatim. // ----------------------------------------------------------------------------------------- [Fact] public void BuildRebirthNcmd_EncodesControlMetric_AndTopic() { var (topic, bytes) = RebirthRequester.Build("Plant1", "EdgeA"); topic.ShouldBe("spBv1.0/Plant1/NCMD/EdgeA"); var p = SparkplugCodec.Decode(bytes); p.Metrics.ShouldContain(m => m.Name == "Node Control/Rebirth" && Equals(m.Value, true)); } /// /// Falsifiability control for the test above: a metric named anything else must NOT satisfy the /// assertion — proves the ShouldContain predicate is actually discriminating on the name, /// not vacuously true because always returns something. /// [Fact] public void BuildRebirthNcmd_DoesNotContainAWrongMetricName() { var (_, bytes) = RebirthRequester.Build("Plant1", "EdgeA"); var p = SparkplugCodec.Decode(bytes); p.Metrics.ShouldNotContain(m => m.Name == "Node Control/NotRebirth" && Equals(m.Value, true)); } /// /// Falsifiability control: a boolean false must not satisfy the plan's assertion either — /// proves the test is checking the metric's value, not merely its presence. /// [Fact] public void BuildRebirthNcmd_ValueIsTrue_NotFalse() { var (_, bytes) = RebirthRequester.Build("Plant1", "EdgeA"); var p = SparkplugCodec.Decode(bytes); p.Metrics.ShouldNotContain(m => m.Name == "Node Control/Rebirth" && Equals(m.Value, false)); } [Fact] public void Build_DifferentGroupAndNode_ProducesMatchingTopic() { var (topic, _) = RebirthRequester.Build("Line7", "Filler42"); topic.ShouldBe("spBv1.0/Line7/NCMD/Filler42"); } [Theory] [InlineData(null, "EdgeA")] [InlineData("", "EdgeA")] [InlineData("Plant1", null)] [InlineData("Plant1", "")] public void Build_MissingGroupOrNode_Throws(string? groupId, string? edgeNodeId) => Should.Throw(() => RebirthRequester.Build(groupId!, edgeNodeId!)); // ----------------------------------------------------------------------------------------- // Encoded metric shape — datatype + no bogus seq // ----------------------------------------------------------------------------------------- [Fact] public void Build_MetricCarriesBooleanDatatype() { var (_, bytes) = RebirthRequester.Build("Plant1", "EdgeA"); var p = SparkplugCodec.Decode(bytes); var metric = p.Metrics.Single(m => m.Name == RebirthRequester.RebirthMetricName); metric.DataType.ShouldBe(TahuDataType.Boolean); } /// /// A host-issued NCMD is not part of the edge node's own outbound sequence stream (Sparkplug /// §6.4.1) — stamping a seq here would be meaningless at best. See the design rationale /// on . /// [Fact] public void Build_PayloadCarriesNoSeq() { var (_, bytes) = RebirthRequester.Build("Plant1", "EdgeA"); var p = SparkplugCodec.Decode(bytes); p.Seq.ShouldBeNull(); } [Fact] public void Build_PayloadAndMetricCarryATimestamp() { var (_, bytes) = RebirthRequester.Build("Plant1", "EdgeA"); var p = SparkplugCodec.Decode(bytes); p.TimestampMs.ShouldNotBeNull(); var metric = p.Metrics.Single(m => m.Name == RebirthRequester.RebirthMetricName); metric.TimestampMs.ShouldNotBeNull(); } // ----------------------------------------------------------------------------------------- // RequestAsync — the bounded publish seam // ----------------------------------------------------------------------------------------- private sealed class RecordingTransport : IMqttPublishTransport { public int PublishCount { get; private set; } public string? LastTopic { get; private set; } public byte[]? LastPayload { get; private set; } public int LastQos { get; private set; } = -1; public bool LastRetain { get; private set; } public Task PublishAsync(string topic, byte[] payload, int qos, bool retain, CancellationToken cancellationToken) { PublishCount++; LastTopic = topic; LastPayload = payload; LastQos = qos; LastRetain = retain; return Task.CompletedTask; } } [Fact] public async Task RequestAsync_PublishesExactlyOnce_WithBuiltTopicAndBytes() { var transport = new RecordingTransport(); await RebirthRequester.RequestAsync(transport, "Plant1", "EdgeA", TestContext.Current.CancellationToken); transport.PublishCount.ShouldBe(1); transport.LastTopic.ShouldBe("spBv1.0/Plant1/NCMD/EdgeA"); var decoded = SparkplugCodec.Decode(transport.LastPayload!); decoded.Metrics.ShouldContain(m => m.Name == "Node Control/Rebirth" && Equals(m.Value, true)); } /// /// Retain MUST be false — a retained NCMD replays to the edge node on every future /// subscribe/reconnect, driving a permanent rebirth loop from one stale command (see the /// carry-forward landmine remarks on ). /// [Fact] public async Task RequestAsync_NeverRetains() { var transport = new RecordingTransport(); await RebirthRequester.RequestAsync(transport, "Plant1", "EdgeA", TestContext.Current.CancellationToken); transport.LastRetain.ShouldBeFalse(); } [Fact] public async Task RequestAsync_UsesQos0() { var transport = new RecordingTransport(); await RebirthRequester.RequestAsync(transport, "Plant1", "EdgeA", TestContext.Current.CancellationToken); transport.LastQos.ShouldBe(0); } [Fact] public async Task RequestAsync_NullTransport_Throws() => await Should.ThrowAsync( () => RebirthRequester.RequestAsync(null!, "Plant1", "EdgeA", TestContext.Current.CancellationToken)); /// /// A transport that hangs forever must not be able to park the caller — the cross-cutting rule /// every network call in this driver follows (mirrors MqttConnection.SubscribeAsync). /// private sealed class HangingTransport : IMqttPublishTransport { public Task PublishAsync(string topic, byte[] payload, int qos, bool retain, CancellationToken cancellationToken) => Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); } [Fact] public async Task RequestAsync_BoundedByDeadline_ThrowsRatherThanHangs() { var transport = new HangingTransport(); await Should.ThrowAsync(() => RebirthRequester.RequestAsync( transport, "Plant1", "EdgeA", TestContext.Current.CancellationToken, timeout: TimeSpan.FromMilliseconds(50))); } [Fact] public async Task RequestAsync_CallerCancellation_Propagates() { var transport = new HangingTransport(); using var cts = new CancellationTokenSource(); await cts.CancelAsync(); await Should.ThrowAsync( () => RebirthRequester.RequestAsync(transport, "Plant1", "EdgeA", cts.Token)); } }