Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/SparkplugIngestorRecoveryTests.cs
T

269 lines
12 KiB
C#

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;
/// <summary>
/// The two push-model edge behaviours <see cref="SparkplugIngestor"/> lacked (archreview
/// 05/STAB-18), matching <see cref="MqttSubscriptionManagerRecoveryTests"/> on the plain path: an
/// <b>initial push</b> of the cached snapshot on every (re)subscribe, and a
/// <b>connection-loss stale</b> to <c>BadNoCommunication</c> with the value retained.
///
/// <para><b>Where the falsifier actually is on this seam — measured, not assumed.</b> Recovery is
/// proved with an <b>identical</b> DBIRTH value, as on the plain path. But Sparkplug ingestion
/// carries <em>no</em> unchanged-value suppression (it publishes births and data directly rather
/// than through <c>MqttSubscriptionManager.Deliver</c>), so that replay was already raised Good
/// before this change — pinned below by
/// <see cref="An_identical_birth_replay_is_raised_even_without_a_preceding_stale"/>. The
/// load-bearing assertion here is therefore the <b>stale</b>: a Sparkplug tag now reports Bad on
/// the driver's own disconnect edge instead of Good over a dead broker.</para>
///
/// <para><b>A death is not a connection loss.</b> DDEATH/NDEATH is the source announcing it is
/// offline and keeps its own <c>BadCommunicationError</c> stale, unchanged; <c>ConnectionLost</c>
/// is this driver's transport losing the broker and reports <c>BadNoCommunication</c> — the same
/// code the host's comms-loss fan-out writes. The DDEATH regression case below pins that the two
/// paths stayed distinct.</para>
/// </summary>
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<Raised> Record(SparkplugIngestor ing)
{
var raised = new List<Raised>();
ing.OnDataChange += (_, e) => raised.Add(new Raised(e.FullReference, e.Snapshot.Value, e.Snapshot.StatusCode));
return raised;
}
// ---------------------------------------------------------------------------------
// (A) Initial push on (re)subscribe
// ---------------------------------------------------------------------------------
/// <summary>
/// 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 <c>BadWaitingForInitialData</c> and
/// triggers no rebirth, so without the push a quiet metric never refills.
/// </summary>
[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);
}
/// <summary>
/// A metric that has never been observed carries only the cache's
/// <c>BadWaitingForInitialData</c> placeholder, and pushing that would claim an observation the
/// driver has not made.
/// </summary>
[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
// ---------------------------------------------------------------------------------
/// <summary>
/// A lost broker session marks every observed metric <c>BadNoCommunication</c> with its last
/// value retained. <c>ResetSessionState</c>'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.
/// </summary>
[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);
}
/// <summary>
/// <b>The contract test for this seam.</b> The reconnect replays NBIRTH/DBIRTH carrying the
/// <b>identical</b> 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.
/// </summary>
[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));
}
/// <summary>
/// <b>Vacuity control, and it changes what the test above proves.</b> 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.
/// </summary>
[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));
}
/// <summary>
/// Regression: a DDEATH still stales with <c>BadCommunicationError</c>, the source-announced
/// code. The connection-loss stale is a second, distinct path — it did not replace this one.
/// </summary>
[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<SparkplugIngestor> 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,
};
}