feat(mqtt): RebirthRequester NCMD encode
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
This commit is contained in:
@@ -0,0 +1,208 @@
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// Covers <see cref="RebirthRequester"/> (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.
|
||||
/// </summary>
|
||||
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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Falsifiability control for the test above: a metric named anything else must NOT satisfy the
|
||||
/// assertion — proves the <c>ShouldContain</c> predicate is actually discriminating on the name,
|
||||
/// not vacuously true because <see cref="SparkplugCodec.Decode"/> always returns something.
|
||||
/// </summary>
|
||||
[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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Falsifiability control: a boolean <c>false</c> must not satisfy the plan's assertion either —
|
||||
/// proves the test is checking the metric's <i>value</i>, not merely its presence.
|
||||
/// </summary>
|
||||
[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<ArgumentException>(() => 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A host-issued NCMD is not part of the edge node's own outbound sequence stream (Sparkplug
|
||||
/// §6.4.1) — stamping a <c>seq</c> here would be meaningless at best. See the design rationale
|
||||
/// on <see cref="RebirthRequester"/>.
|
||||
/// </summary>
|
||||
[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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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 <see cref="RebirthRequester"/>).
|
||||
/// </summary>
|
||||
[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<ArgumentNullException>(
|
||||
() => RebirthRequester.RequestAsync(null!, "Plant1", "EdgeA", TestContext.Current.CancellationToken));
|
||||
|
||||
/// <summary>
|
||||
/// 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 <c>MqttConnection.SubscribeAsync</c>).
|
||||
/// </summary>
|
||||
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<OperationCanceledException>(() => 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<OperationCanceledException>(
|
||||
() => RebirthRequester.RequestAsync(transport, "Plant1", "EdgeA", cts.Token));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user