6d7a458c4d
Task 21 review — two Criticals sharing one root cause, plus four follow-ups.
C1/C2 root cause: `OnReconnectedAsync` opened with `Births.Clear(); _trackers.Clear();`
— "not housekeeping, the correctness step" — and that reset was missing from the
other two seams where the view underneath a surviving cache changes.
- C1: the ingestor OUTLIVES a session rebuild. `SessionIdentity` is a strict
superset of `IngestIdentity`, so changing only Host/Port/ClientId/credentials/
TLS/a timeout gives `!SameSession && SameIngest` — `ReinitializeAsync` tears the
session down and re-establishes on the same instance, and `TeardownAsync` touches
no ingest state (the resilience layer re-running `InitializeAsync` is the second
path). A node that rebound alias 5 during the changeover would publish Pressure's
value under the Temperature RawPath at Good. Hoisted into `ResetSessionState`,
called from `AttachTo` (earliest seam — a persistent session can push between
CONNACK and our SUBSCRIBE), `EstablishAsync` and `OnReconnectedAsync`.
- C2: `Register` pruned trackers but not births. While a node is unauthored its
traffic is dropped at `Dispatch`, so its cached birth FREEZES rather than
refreshing; drop-then-re-add across a week mis-routes at Good, and `Births` grew
monotonically. Now evicted on edge-node departure.
Deliberately NARROWER than "absent from ByScope": a device with no authored tags
under a still-authored node keeps receiving traffic, so its birth is live, not
frozen — evicting it would discard correct state. Pinned both ways.
- I1: a birth whose seq is unreadable produced a permanent debounce-paced rebirth
loop. "A birth arrived" is now tracked separately from "the birth established a
baseline". Writing the test showed the cap belonged wider than the review scoped
it: data-before-birth and unknown-alias loop identically (20 messages → 23 NCMDs
against a cap of 3), so all three missing-metadata reasons share one per-node
consecutive cap, re-armed by any applied birth. A seq gap stays uncapped — it
self-limits.
- I2: the oversize `WarnOnce` key was the raw topic, off a group-wide `#`
subscription. `HandleMessage` now parses and filters to authored nodes BEFORE the
size check (also skipping the protobuf parse for discarded traffic), and `_warned`
carries a hard ceiling so a future bad derivation degrades to silence.
- I3: array metrics slipped the unsupported-type gate — `ToDriverDataType` returns
the ELEMENT type, so a String-typed tag published a packed `byte[]` as base64 at
Good. Gated on `IsSparkplugArray`.
- I4: driver-level Sparkplug coverage for the composition-root lines T22 left
untested — the `OnDataChange` re-raise, `ReadAsync` off the shared cache,
subscribe/unsubscribe dispatch, and both directions of the mode gate.
Minors: M1 the oversize test fed unparseable bytes and passed with the size check
deleted (now a real NBIRTH + an at-the-ceiling control); M2 answered in-place (an
absent seq is refused WITHOUT becoming the baseline, so the NCMD assertion is the
complete check); M3 `HostStateObserved` raise wrapped; M5 the legacy `STATE/{host}`
form is now subscribed, so the parser's tolerance is reachable in production; M8
`ResolveBinding` prefers the NAME over a disagreeing alias; M9 a narrowing float
conversion refuses instead of publishing ±Infinity at Good (a publisher's own
infinity still passes through). M4 was already resolved by T22.
Also fixed a race in the new tests: `publish.Topics.Clear()` after a call that
dispatches rebirths off-thread must drain first — it made the C1 sequence-baseline
pin pass vacuously in the first falsifiability run.
Falsifiability: dropping the reset from AttachTo+EstablishAsync reddens 4 (C1);
dropping the Register eviction reddens 2 (C2); widening the eviction predicate to
the reviewer's literal phrasing reddens the keep-live-births pin (C2b). 545/545 MQTT
unit tests green (was 524); whole-solution `--no-incremental` build clean under TWAE.
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
1727 lines
73 KiB
C#
1727 lines
73 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>
|
|
/// Covers <see cref="SparkplugIngestor"/> (Task 21) — the design doc's §3.6 correctness matrix:
|
|
/// rebirth, missed birth, seq gap, alias reuse across a rebirth, death → stale → rebirth, plus
|
|
/// data-before-birth and unknown-alias. Every test runs without a broker: payloads are built with
|
|
/// the generated Tahu types, decoded through the real <see cref="SparkplugCodec"/>, and fed to
|
|
/// <see cref="SparkplugIngestor.Dispatch"/> (the method <see cref="SparkplugIngestor.HandleMessage"/>
|
|
/// calls on MQTTnet's dispatcher thread).
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <b>The identity assertion is the point of half of these.</b> The plan's illustrative snippet
|
|
/// asserts <c>firedRef.ShouldContain("Filler1:Temperature")</c> — the retired pre-v3 shape. A
|
|
/// Sparkplug-composed reference satisfies it and is silently dead against <c>DriverHostActor</c>'s
|
|
/// RawPath-keyed dual-namespace fan-out, so every publish here is asserted to <b>be</b> the RawPath
|
|
/// and to <b>not</b> be the composed form.
|
|
/// </remarks>
|
|
public sealed class SparkplugIngestorTests
|
|
{
|
|
// 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 BadTypeMismatch = 0x80740000u;
|
|
private const uint BadNodeIdUnknown = 0x80340000u;
|
|
|
|
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 string CountPath = "Plant/Mqtt/spb/Filler1Count";
|
|
private const string NodeUptimePath = "Plant/Mqtt/spb/EdgeAUptime";
|
|
|
|
/// <summary>The retired pre-v3 composed shape — asserted ABSENT, never present.</summary>
|
|
private const string ComposedRef = "Plant1/EdgeA/Filler1:Temperature";
|
|
|
|
private const ulong TempAlias = 5UL;
|
|
private const ulong PressAlias = 6UL;
|
|
private const ulong CountAlias = 9UL;
|
|
|
|
// ---------------------------------------------------------------------------------
|
|
// §3.6 #1/#2 — birth → data, resolved BY ALIAS, published UNDER THE RAWPATH
|
|
// ---------------------------------------------------------------------------------
|
|
|
|
/// <summary>
|
|
/// The plan's first snippet, corrected: the published reference is asserted to BE the tag's
|
|
/// RawPath, not merely to contain the composed <c>Filler1:Temperature</c> form.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task BirthThenData_ResolvesByAlias_RaisesOnDataChangeUnderRawPath()
|
|
{
|
|
var ing = await NewSubscribedIngestorAsync();
|
|
|
|
string? firedRef = null;
|
|
object? firedValue = null;
|
|
ing.OnDataChange += (_, e) => { firedRef = e.FullReference; firedValue = e.Snapshot.Value; };
|
|
|
|
ing.Dispatch(Topic(SparkplugMessageType.NBIRTH), Nbirth(seq: 0, bdSeq: 1));
|
|
ing.Dispatch(Topic(SparkplugMessageType.DBIRTH, Device), Dbirth(seq: 1));
|
|
ing.Dispatch(
|
|
Topic(SparkplugMessageType.DDATA, Device),
|
|
Ddata(seq: 2, (TempAlias, TahuDataType.Float, 21.5f)));
|
|
|
|
firedRef.ShouldBe(TempPath);
|
|
firedRef.ShouldNotBe(ComposedRef);
|
|
firedValue.ShouldBe(21.5f);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Falsifiability control for the identity assertion above: the composed Sparkplug reference —
|
|
/// the shape the plan's own snippet would have accepted — must never be published.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task PublishedReference_IsNeverTheComposedSparkplugForm()
|
|
{
|
|
var ing = await NewSubscribedIngestorAsync();
|
|
var refs = new List<string>();
|
|
ing.OnDataChange += (_, e) => refs.Add(e.FullReference);
|
|
|
|
FeedBirths(ing);
|
|
ing.Dispatch(
|
|
Topic(SparkplugMessageType.DDATA, Device),
|
|
Ddata(seq: 2, (TempAlias, TahuDataType.Float, 21.5f)));
|
|
|
|
refs.ShouldNotBeEmpty();
|
|
refs.ShouldAllBe(r => r == TempPath || r == PressPath || r == CountPath || r == NodeUptimePath);
|
|
refs.ShouldNotContain(ComposedRef);
|
|
}
|
|
|
|
/// <summary>A DATA metric carries an alias and NO name — the shape every real Sparkplug DATA has.</summary>
|
|
[Fact]
|
|
public async Task DataMetric_CarriesNoNameOrDatatype_StillResolves()
|
|
{
|
|
var ing = await NewSubscribedIngestorAsync();
|
|
FeedBirths(ing);
|
|
|
|
var payload = Ddata(seq: 2, (TempAlias, TahuDataType.Float, 33.5f));
|
|
payload.Metrics.ShouldAllBe(m => m.Name == null && m.DataType == null);
|
|
|
|
var seen = new Dictionary<string, DataValueSnapshot>(StringComparer.Ordinal);
|
|
ing.OnDataChange += (_, e) => seen[e.FullReference] = e.Snapshot;
|
|
ing.Dispatch(Topic(SparkplugMessageType.DDATA, Device), payload);
|
|
|
|
seen[TempPath].Value.ShouldBe(33.5f);
|
|
seen[TempPath].StatusCode.ShouldBe(Good);
|
|
}
|
|
|
|
/// <summary>A node-scoped metric (NDATA) binds against the node's own scope, not a device's.</summary>
|
|
[Fact]
|
|
public async Task NodeScopedMetric_BindsToTheNodeScope()
|
|
{
|
|
var ing = await NewSubscribedIngestorAsync();
|
|
var seen = new Dictionary<string, object?>(StringComparer.Ordinal);
|
|
ing.OnDataChange += (_, e) => seen[e.FullReference] = e.Snapshot.Value;
|
|
|
|
ing.Dispatch(Topic(SparkplugMessageType.NBIRTH), Nbirth(seq: 0, bdSeq: 1));
|
|
ing.Dispatch(Topic(SparkplugMessageType.NDATA), Ndata(seq: 1, (7UL, TahuDataType.Int64, 99L)));
|
|
|
|
seen[NodeUptimePath].ShouldBe(99L);
|
|
seen.ShouldNotContainKey(TempPath);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------------
|
|
// §3.6 #1 — ALIAS REUSE ACROSS A REBIRTH (the mis-route this whole design prevents)
|
|
// ---------------------------------------------------------------------------------
|
|
|
|
/// <summary>
|
|
/// The single most important test in the file. An edge node is free to point alias 5 at a
|
|
/// different metric after a rebirth. Binding a tag to an alias silently routes Pressure into the
|
|
/// Temperature tag — good quality, plausible value, completely wrong.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task AliasReusedAcrossRebirth_RoutesByName_NotByAlias()
|
|
{
|
|
var ing = await NewSubscribedIngestorAsync();
|
|
var seen = new Dictionary<string, object?>(StringComparer.Ordinal);
|
|
ing.OnDataChange += (_, e) => seen[e.FullReference] = e.Snapshot.Value;
|
|
|
|
ing.Dispatch(Topic(SparkplugMessageType.NBIRTH), Nbirth(seq: 0, bdSeq: 1));
|
|
ing.Dispatch(Topic(SparkplugMessageType.DBIRTH, Device), Dbirth(seq: 1));
|
|
ing.Dispatch(
|
|
Topic(SparkplugMessageType.DDATA, Device),
|
|
Ddata(seq: 2, (TempAlias, TahuDataType.Float, 21.5f)));
|
|
seen[TempPath].ShouldBe(21.5f);
|
|
|
|
// Rebirth: the SAME alias 5 now names Pressure, and Temperature moves to alias 6.
|
|
ing.Dispatch(Topic(SparkplugMessageType.NBIRTH), Nbirth(seq: 0, bdSeq: 2));
|
|
ing.Dispatch(
|
|
Topic(SparkplugMessageType.DBIRTH, Device),
|
|
DbirthCustom(
|
|
seq: 1,
|
|
("Pressure", TempAlias, TahuDataType.Float, 1.0f),
|
|
("Temperature", PressAlias, TahuDataType.Float, 2.0f)));
|
|
|
|
seen.Clear();
|
|
ing.Dispatch(
|
|
Topic(SparkplugMessageType.DDATA, Device),
|
|
Ddata(seq: 2, (TempAlias, TahuDataType.Float, 4.25f)));
|
|
|
|
// Alias 5 is Pressure NOW. A tag bound by alias would put 4.25 on the Temperature RawPath.
|
|
seen.ShouldContainKey(PressPath);
|
|
seen[PressPath].ShouldBe(4.25f);
|
|
seen.ShouldNotContainKey(TempPath);
|
|
}
|
|
|
|
/// <summary>
|
|
/// A rebirth REPLACES the catalog: an alias the new birth does not declare must stop resolving,
|
|
/// rather than lingering from the previous birth's table.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Rebirth_DroppingAMetric_StopsFeedingIt()
|
|
{
|
|
var ing = await NewSubscribedIngestorAsync();
|
|
var seen = new List<string>();
|
|
ing.OnDataChange += (_, e) => seen.Add(e.FullReference);
|
|
|
|
FeedBirths(ing);
|
|
ing.Dispatch(Topic(SparkplugMessageType.NBIRTH), Nbirth(seq: 0, bdSeq: 2));
|
|
ing.Dispatch(
|
|
Topic(SparkplugMessageType.DBIRTH, Device),
|
|
DbirthCustom(seq: 1, ("Pressure", PressAlias, TahuDataType.Float, 0f)));
|
|
|
|
seen.Clear();
|
|
ing.Dispatch(
|
|
Topic(SparkplugMessageType.DDATA, Device),
|
|
Ddata(seq: 2, (TempAlias, TahuDataType.Float, 21.5f)));
|
|
|
|
seen.ShouldNotContain(TempPath);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------------
|
|
// §3.6 #3 — data before birth / unknown alias / seq gap ⇒ rebirth NCMD
|
|
// ---------------------------------------------------------------------------------
|
|
|
|
/// <summary>The plan's second snippet, against the real NCMD publish seam.</summary>
|
|
[Fact]
|
|
public async Task DataBeforeBirth_RequestsRebirth()
|
|
{
|
|
var publish = new RecordingPublishTransport();
|
|
var ing = await NewSubscribedIngestorAsync(publish);
|
|
|
|
ing.Dispatch(
|
|
Topic(SparkplugMessageType.DDATA, Device),
|
|
Ddata(seq: 0, (CountAlias, TahuDataType.Float, 1f)));
|
|
await ing.DrainPendingRebirthsAsync();
|
|
|
|
publish.Topics.ShouldContain("spBv1.0/Plant1/NCMD/EdgeA");
|
|
}
|
|
|
|
/// <summary>An alias the current birth never declared is our metadata being behind the node's.</summary>
|
|
[Fact]
|
|
public async Task UnknownAlias_RequestsRebirth()
|
|
{
|
|
var publish = new RecordingPublishTransport();
|
|
var ing = await NewSubscribedIngestorAsync(publish);
|
|
FeedBirths(ing);
|
|
publish.Topics.Clear();
|
|
|
|
ing.Dispatch(
|
|
Topic(SparkplugMessageType.DDATA, Device),
|
|
Ddata(seq: 2, (4242UL, TahuDataType.Float, 1f)));
|
|
await ing.DrainPendingRebirthsAsync();
|
|
|
|
publish.Topics.ShouldContain("spBv1.0/Plant1/NCMD/EdgeA");
|
|
}
|
|
|
|
/// <summary>A missed message in the edge node's stream — §3.6 #3.</summary>
|
|
[Fact]
|
|
public async Task SeqGap_RequestsRebirth()
|
|
{
|
|
var publish = new RecordingPublishTransport();
|
|
var ing = await NewSubscribedIngestorAsync(publish);
|
|
FeedBirths(ing);
|
|
publish.Topics.Clear();
|
|
|
|
// seq 2 was expected; 4 means two messages were lost.
|
|
ing.Dispatch(
|
|
Topic(SparkplugMessageType.DDATA, Device),
|
|
Ddata(seq: 4, (TempAlias, TahuDataType.Float, 1f)));
|
|
await ing.DrainPendingRebirthsAsync();
|
|
|
|
publish.Topics.ShouldContain("spBv1.0/Plant1/NCMD/EdgeA");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Falsifiability control for every rebirth test above: a contiguous, birth-synchronised stream
|
|
/// must publish NOTHING. Without this the rebirth assertions could pass off a driver that
|
|
/// NCMDs on every single message.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task ContiguousStream_RequestsNoRebirth()
|
|
{
|
|
var publish = new RecordingPublishTransport();
|
|
var ing = await NewSubscribedIngestorAsync(publish);
|
|
|
|
FeedBirths(ing);
|
|
ing.Dispatch(Topic(SparkplugMessageType.DDATA, Device), Ddata(seq: 2, (TempAlias, TahuDataType.Float, 1f)));
|
|
ing.Dispatch(Topic(SparkplugMessageType.DDATA, Device), Ddata(seq: 3, (TempAlias, TahuDataType.Float, 2f)));
|
|
await ing.DrainPendingRebirthsAsync();
|
|
|
|
publish.Topics.ShouldBeEmpty();
|
|
}
|
|
|
|
/// <summary>
|
|
/// 255 → 0 is the sequence continuing, not a gap. Reading the wrap as a gap asks every edge node
|
|
/// to rebirth once per 256 messages, so a busy node spends its life republishing births.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task SeqWrapsFrom255To0_IsNotAGap()
|
|
{
|
|
var publish = new RecordingPublishTransport();
|
|
var ing = await NewSubscribedIngestorAsync(publish);
|
|
|
|
ing.Dispatch(Topic(SparkplugMessageType.NBIRTH), Nbirth(seq: 255, bdSeq: 1));
|
|
ing.Dispatch(Topic(SparkplugMessageType.DBIRTH, Device), Dbirth(seq: 0));
|
|
ing.Dispatch(Topic(SparkplugMessageType.DDATA, Device), Ddata(seq: 1, (TempAlias, TahuDataType.Float, 1f)));
|
|
await ing.DrainPendingRebirthsAsync();
|
|
|
|
publish.Topics.ShouldBeEmpty();
|
|
}
|
|
|
|
/// <summary>
|
|
/// A data message whose sequence happens to be contiguous but which arrived with no NBIRTH seen
|
|
/// since connect — the driver joined mid-stream and holds no alias table.
|
|
/// <see cref="SequenceTracker.Accept"/> returns <see langword="true"/> for it, so a driver that
|
|
/// only read the return value would sit happily behind a stream it cannot decode.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task LateJoin_FirstMessageIsNotABirth_RequestsRebirth()
|
|
{
|
|
var publish = new RecordingPublishTransport();
|
|
var ing = await NewSubscribedIngestorAsync(publish);
|
|
|
|
ing.Dispatch(Topic(SparkplugMessageType.DBIRTH, Device), Dbirth(seq: 17));
|
|
await ing.DrainPendingRebirthsAsync();
|
|
|
|
publish.Topics.ShouldContain("spBv1.0/Plant1/NCMD/EdgeA");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Repeatedly commanding a node that is already republishing IS the storm the design warns
|
|
/// about. Three unroutable messages in a row must produce one NCMD, not three.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task RepeatedRebirthTriggers_AreDebouncedPerEdgeNode()
|
|
{
|
|
var publish = new RecordingPublishTransport();
|
|
var ing = await NewSubscribedIngestorAsync(publish, rebirthDebounce: TimeSpan.FromMinutes(5));
|
|
|
|
for (var i = 0; i < 3; i++)
|
|
{
|
|
ing.Dispatch(
|
|
Topic(SparkplugMessageType.DDATA, Device),
|
|
Ddata(seq: (ulong)i, (CountAlias, TahuDataType.Float, 1f)));
|
|
}
|
|
|
|
await ing.DrainPendingRebirthsAsync();
|
|
|
|
publish.Topics.Count.ShouldBe(1);
|
|
}
|
|
|
|
/// <summary>The debounce floor is per edge node, not global — a second node must not be starved.</summary>
|
|
[Fact]
|
|
public async Task RebirthDebounce_IsPerEdgeNode()
|
|
{
|
|
var publish = new RecordingPublishTransport();
|
|
var ing = await NewSubscribedIngestorAsync(
|
|
publish,
|
|
rebirthDebounce: TimeSpan.FromMinutes(5),
|
|
extraTags: [Tag("Plant/Mqtt/spb/EdgeBTemp", Blob(Group, "EdgeB", Device, "Temperature", "Float32"))]);
|
|
|
|
ing.Dispatch(Topic(SparkplugMessageType.DDATA, Device), Ddata(seq: 0, (CountAlias, TahuDataType.Float, 1f)));
|
|
ing.Dispatch(
|
|
new SparkplugTopic(SparkplugMessageType.DDATA, Group, "EdgeB", Device, null),
|
|
Ddata(seq: 0, (CountAlias, TahuDataType.Float, 1f)));
|
|
await ing.DrainPendingRebirthsAsync();
|
|
|
|
publish.Topics.ShouldContain("spBv1.0/Plant1/NCMD/EdgeA");
|
|
publish.Topics.ShouldContain("spBv1.0/Plant1/NCMD/EdgeB");
|
|
}
|
|
|
|
/// <summary><c>requestRebirthOnGap = false</c> is the operator's off switch for ALL outbound NCMDs.</summary>
|
|
[Fact]
|
|
public async Task RequestRebirthOnGapDisabled_PublishesNothing()
|
|
{
|
|
var publish = new RecordingPublishTransport();
|
|
var ing = await NewSubscribedIngestorAsync(publish, requestRebirthOnGap: false);
|
|
|
|
ing.Dispatch(Topic(SparkplugMessageType.DDATA, Device), Ddata(seq: 0, (CountAlias, TahuDataType.Float, 1f)));
|
|
await ing.DrainPendingRebirthsAsync();
|
|
|
|
publish.Topics.ShouldBeEmpty();
|
|
}
|
|
|
|
/// <summary>A missing publish transport degrades to a log, never an exception on the dispatcher.</summary>
|
|
[Fact]
|
|
public async Task RebirthWithNoPublishTransport_DoesNotThrow()
|
|
{
|
|
var ing = await NewSubscribedIngestorAsync(publish: null);
|
|
|
|
Should.NotThrow(() => ing.Dispatch(
|
|
Topic(SparkplugMessageType.DDATA, Device),
|
|
Ddata(seq: 0, (CountAlias, TahuDataType.Float, 1f))));
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------------
|
|
// §3.6 #4 — death → STALE, next birth restores Good
|
|
// ---------------------------------------------------------------------------------
|
|
|
|
/// <summary>The plan's third snippet.</summary>
|
|
[Fact]
|
|
public async Task Ddeath_EmitsBadForDeviceMetrics()
|
|
{
|
|
var ing = await NewSubscribedIngestorAsync();
|
|
FeedBirths(ing);
|
|
|
|
var quals = new List<uint>();
|
|
ing.OnDataChange += (_, e) => quals.Add(e.Snapshot.StatusCode);
|
|
|
|
ing.Dispatch(Topic(SparkplugMessageType.DDEATH, Device), Ddeath(seq: 2));
|
|
|
|
quals.ShouldNotBeEmpty();
|
|
quals.ShouldAllBe(q => q == BadCommunicationError);
|
|
}
|
|
|
|
/// <summary>The completing half of §3.6 #4 — a birth is a full snapshot, so it restores Good.</summary>
|
|
[Fact]
|
|
public async Task DdeathThenDbirth_RestoresGood()
|
|
{
|
|
var ing = await NewSubscribedIngestorAsync();
|
|
FeedBirths(ing);
|
|
|
|
var last = new Dictionary<string, DataValueSnapshot>(StringComparer.Ordinal);
|
|
ing.OnDataChange += (_, e) => last[e.FullReference] = e.Snapshot;
|
|
|
|
ing.Dispatch(Topic(SparkplugMessageType.DDEATH, Device), Ddeath(seq: 2));
|
|
last[TempPath].StatusCode.ShouldBe(BadCommunicationError);
|
|
|
|
ing.Dispatch(Topic(SparkplugMessageType.DBIRTH, Device), Dbirth(seq: 3));
|
|
|
|
last[TempPath].StatusCode.ShouldBe(Good);
|
|
last[TempPath].Value.ShouldBe(0f);
|
|
}
|
|
|
|
/// <summary>A DDEATH stales exactly its own device; the node's own metrics keep flowing.</summary>
|
|
[Fact]
|
|
public async Task Ddeath_DoesNotStaleTheNodesOwnMetrics()
|
|
{
|
|
var ing = await NewSubscribedIngestorAsync();
|
|
FeedBirths(ing);
|
|
|
|
var last = new Dictionary<string, DataValueSnapshot>(StringComparer.Ordinal);
|
|
ing.OnDataChange += (_, e) => last[e.FullReference] = e.Snapshot;
|
|
|
|
ing.Dispatch(Topic(SparkplugMessageType.DDEATH, Device), Ddeath(seq: 2));
|
|
|
|
last.ShouldContainKey(TempPath);
|
|
last.ShouldNotContainKey(NodeUptimePath);
|
|
}
|
|
|
|
/// <summary>An NDEATH kills the node AND every device under it.</summary>
|
|
[Fact]
|
|
public async Task Ndeath_StalesTheNodeAndEveryDeviceUnderIt()
|
|
{
|
|
var ing = await NewSubscribedIngestorAsync();
|
|
FeedBirths(ing);
|
|
|
|
var last = new Dictionary<string, DataValueSnapshot>(StringComparer.Ordinal);
|
|
ing.OnDataChange += (_, e) => last[e.FullReference] = e.Snapshot;
|
|
|
|
ing.Dispatch(Topic(SparkplugMessageType.NDEATH), Ndeath(bdSeq: 1));
|
|
|
|
last[TempPath].StatusCode.ShouldBe(BadCommunicationError);
|
|
last[NodeUptimePath].StatusCode.ShouldBe(BadCommunicationError);
|
|
}
|
|
|
|
/// <summary>
|
|
/// The <c>bdSeq</c> tie of §3.6 #3. A node's connection drops, it reconnects and re-births, and
|
|
/// only THEN does the broker deliver the old session's Last Will. Acting on it marks a live node
|
|
/// dead with nothing coming to correct it.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task StaleNdeath_FromAPreviousSession_IsIgnored()
|
|
{
|
|
var ing = await NewSubscribedIngestorAsync();
|
|
|
|
ing.Dispatch(Topic(SparkplugMessageType.NBIRTH), Nbirth(seq: 0, bdSeq: 2));
|
|
ing.Dispatch(Topic(SparkplugMessageType.DBIRTH, Device), Dbirth(seq: 1));
|
|
|
|
var quals = new List<uint>();
|
|
ing.OnDataChange += (_, e) => quals.Add(e.Snapshot.StatusCode);
|
|
|
|
ing.Dispatch(Topic(SparkplugMessageType.NDEATH), Ndeath(bdSeq: 1));
|
|
|
|
quals.ShouldBeEmpty();
|
|
ing.Births.Find(new SparkplugScope(Group, Node, Device)).ShouldNotBeNull();
|
|
}
|
|
|
|
/// <summary>
|
|
/// <b>NDEATH must never reach <see cref="SequenceTracker.Accept"/>.</b> It is the broker-published
|
|
/// Last Will and carries no <c>seq</c> at all, so <c>Accept</c> would report a gap on every death
|
|
/// and answer a node that has just died with a rebirth request it can never serve.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Ndeath_DoesNotAdvanceTheSequenceTracker_AndRequestsNoRebirth()
|
|
{
|
|
var publish = new RecordingPublishTransport();
|
|
var ing = await NewSubscribedIngestorAsync(publish);
|
|
|
|
FeedBirths(ing);
|
|
publish.Topics.Clear();
|
|
|
|
ing.Dispatch(Topic(SparkplugMessageType.NDEATH), Ndeath(bdSeq: 1));
|
|
await ing.DrainPendingRebirthsAsync();
|
|
|
|
// This single assertion IS the complete check, and the review question "does it also verify the
|
|
// baseline?" has an answer rather than a gap: SequenceTracker.Accept refuses an absent seq
|
|
// WITHOUT adopting it as the baseline, so routing the death through it has exactly one
|
|
// observable consequence — a reported gap, and therefore this NCMD. Falsifiability run (b)
|
|
// confirmed it: inserting CheckSequence into OnNodeDeath reddens this test and only this test.
|
|
//
|
|
// A follow-up DDATA would NOT strengthen it. An accepted NDEATH evicts the node's births, so
|
|
// the next data message is legitimately data-before-birth and asks for a rebirth on its own
|
|
// merits — asserting silence there would pin the wrong behaviour.
|
|
publish.Topics.ShouldBeEmpty();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Per the Sparkplug spec an NBIRTH voids every prior DBIRTH under that node: those devices MUST
|
|
/// re-DBIRTH, and until they do their aliases mean nothing.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Nbirth_InvalidatesPriorDeviceBirths_AndStalesThem()
|
|
{
|
|
var ing = await NewSubscribedIngestorAsync();
|
|
FeedBirths(ing);
|
|
|
|
var last = new Dictionary<string, DataValueSnapshot>(StringComparer.Ordinal);
|
|
ing.OnDataChange += (_, e) => last[e.FullReference] = e.Snapshot;
|
|
|
|
ing.Dispatch(Topic(SparkplugMessageType.NBIRTH), Nbirth(seq: 0, bdSeq: 2));
|
|
|
|
last[TempPath].StatusCode.ShouldBe(BadCommunicationError);
|
|
ing.Births.Find(new SparkplugScope(Group, Node, Device)).ShouldBeNull();
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------------
|
|
// Wire-level value correctness
|
|
// ---------------------------------------------------------------------------------
|
|
|
|
/// <summary>
|
|
/// Sparkplug carries Int8/16/32/64 as two's complement in an <b>unsigned</b> proto field, so a
|
|
/// metric whose value is -43 arrives as 4294967253. Skipping
|
|
/// <see cref="SparkplugMetricBinding.Reinterpret"/> publishes a plausible, Good-quality,
|
|
/// completely wrong number — or, once the tag is Int32, an out-of-range refusal.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task NegativeInteger_IsReinterpretedFromItsTwosComplementWireValue()
|
|
{
|
|
var ing = await NewSubscribedIngestorAsync();
|
|
FeedBirths(ing);
|
|
|
|
var seen = new Dictionary<string, DataValueSnapshot>(StringComparer.Ordinal);
|
|
ing.OnDataChange += (_, e) => seen[e.FullReference] = e.Snapshot;
|
|
|
|
ing.Dispatch(
|
|
Topic(SparkplugMessageType.DDATA, Device),
|
|
Ddata(seq: 2, (CountAlias, TahuDataType.Int32, -43)));
|
|
|
|
seen[CountPath].StatusCode.ShouldBe(Good);
|
|
seen[CountPath].Value.ShouldBe(-43);
|
|
seen[CountPath].Value.ShouldNotBe(4294967253L);
|
|
}
|
|
|
|
/// <summary>The birth's own values are published too — that is how a rebirth restores Good.</summary>
|
|
[Fact]
|
|
public async Task BirthValues_ArePublished()
|
|
{
|
|
var ing = await NewSubscribedIngestorAsync();
|
|
var seen = new Dictionary<string, object?>(StringComparer.Ordinal);
|
|
ing.OnDataChange += (_, e) => seen[e.FullReference] = e.Snapshot.Value;
|
|
|
|
FeedBirths(ing);
|
|
|
|
seen[TempPath].ShouldBe(0f);
|
|
seen[CountPath].ShouldBe(-42);
|
|
}
|
|
|
|
/// <summary>
|
|
/// A tag authored with no <c>dataType</c> takes the type its BIRTH declared — the reason
|
|
/// <c>dataType</c> is optional in the Sparkplug tag shape at all.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task TagWithNoAuthoredDataType_TakesTheBirthsType()
|
|
{
|
|
const string Untyped = "Plant/Mqtt/spb/Untyped";
|
|
var ing = NewIngestor(tags: [Tag(Untyped, """{"groupId":"Plant1","edgeNodeId":"EdgeA","deviceId":"Filler1","metricName":"Temperature"}""")]);
|
|
await ing.SubscribeAsync([Untyped], TimeSpan.Zero, TestContext.Current.CancellationToken);
|
|
|
|
object? value = null;
|
|
ing.OnDataChange += (_, e) => value = e.Snapshot.Value;
|
|
|
|
ing.Dispatch(Topic(SparkplugMessageType.NBIRTH), Nbirth(seq: 0, bdSeq: 1));
|
|
ing.Dispatch(Topic(SparkplugMessageType.DBIRTH, Device), Dbirth(seq: 1));
|
|
ing.Dispatch(Topic(SparkplugMessageType.DDATA, Device), Ddata(seq: 2, (TempAlias, TahuDataType.Float, 7.5f)));
|
|
|
|
// Float32, from the birth — NOT the record's String default, which would have produced "7.5".
|
|
value.ShouldBe(7.5f);
|
|
}
|
|
|
|
/// <summary>An authored <c>dataType</c> wins over the birth's — the operator declared it deliberately.</summary>
|
|
[Fact]
|
|
public async Task AuthoredDataType_WinsOverTheBirths()
|
|
{
|
|
const string AsText = "Plant/Mqtt/spb/TempAsText";
|
|
var ing = NewIngestor(tags: [Tag(AsText, Blob(Group, Node, Device, "Temperature", "String"))]);
|
|
await ing.SubscribeAsync([AsText], TimeSpan.Zero, TestContext.Current.CancellationToken);
|
|
|
|
object? value = null;
|
|
ing.OnDataChange += (_, e) => value = e.Snapshot.Value;
|
|
|
|
ing.Dispatch(Topic(SparkplugMessageType.NBIRTH), Nbirth(seq: 0, bdSeq: 1));
|
|
ing.Dispatch(Topic(SparkplugMessageType.DBIRTH, Device), Dbirth(seq: 1));
|
|
ing.Dispatch(Topic(SparkplugMessageType.DDATA, Device), Ddata(seq: 2, (TempAlias, TahuDataType.Float, 7.5f)));
|
|
|
|
value.ShouldBe("7.5");
|
|
}
|
|
|
|
/// <summary>An explicit Sparkplug <c>is_null</c> is not a value of the tag's declared type.</summary>
|
|
[Fact]
|
|
public async Task ExplicitNullMetric_PublishesBadTypeMismatch()
|
|
{
|
|
var ing = await NewSubscribedIngestorAsync();
|
|
FeedBirths(ing);
|
|
|
|
var seen = new Dictionary<string, DataValueSnapshot>(StringComparer.Ordinal);
|
|
ing.OnDataChange += (_, e) => seen[e.FullReference] = e.Snapshot;
|
|
|
|
var payload = new Payload { Seq = 2UL, Timestamp = 1UL };
|
|
payload.Metrics.Add(new Payload.Types.Metric { Alias = TempAlias, IsNull = true });
|
|
ing.Dispatch(Topic(SparkplugMessageType.DDATA, Device), Decode(payload));
|
|
|
|
seen[TempPath].StatusCode.ShouldBe(BadTypeMismatch);
|
|
seen[TempPath].Value.ShouldBeNull();
|
|
}
|
|
|
|
/// <summary>
|
|
/// A DataSet/Template metric is warn-and-skip — NOT a missing metric and NOT a rebirth trigger.
|
|
/// A node asked to rebirth would answer with the same unsupported metric and the pair would loop.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task UnsupportedMetricKind_IsSkipped_AndRequestsNoRebirth()
|
|
{
|
|
var publish = new RecordingPublishTransport();
|
|
var ing = await NewSubscribedIngestorAsync(publish);
|
|
FeedBirths(ing);
|
|
publish.Topics.Clear();
|
|
|
|
var seen = new List<string>();
|
|
ing.OnDataChange += (_, e) => seen.Add(e.FullReference);
|
|
|
|
var payload = new Payload { Seq = 2UL, Timestamp = 1UL };
|
|
payload.Metrics.Add(new Payload.Types.Metric { Alias = TempAlias, DatasetValue = new Payload.Types.DataSet() });
|
|
ing.Dispatch(Topic(SparkplugMessageType.DDATA, Device), Decode(payload));
|
|
await ing.DrainPendingRebirthsAsync();
|
|
|
|
seen.ShouldBeEmpty();
|
|
publish.Topics.ShouldBeEmpty();
|
|
}
|
|
|
|
/// <summary>A value that does not fit the authored type is refused, never rounded or wrapped.</summary>
|
|
[Fact]
|
|
public async Task FractionalValueForAnIntegerTag_PublishesBadTypeMismatch()
|
|
{
|
|
const string IntTag = "Plant/Mqtt/spb/TempAsInt";
|
|
var ing = NewIngestor(tags: [Tag(IntTag, Blob(Group, Node, Device, "Temperature", "Int32"))]);
|
|
await ing.SubscribeAsync([IntTag], TimeSpan.Zero, TestContext.Current.CancellationToken);
|
|
|
|
DataValueSnapshot? snapshot = null;
|
|
ing.OnDataChange += (_, e) => snapshot = e.Snapshot;
|
|
|
|
ing.Dispatch(Topic(SparkplugMessageType.NBIRTH), Nbirth(seq: 0, bdSeq: 1));
|
|
ing.Dispatch(Topic(SparkplugMessageType.DBIRTH, Device), Dbirth(seq: 1));
|
|
ing.Dispatch(Topic(SparkplugMessageType.DDATA, Device), Ddata(seq: 2, (TempAlias, TahuDataType.Float, 21.5f)));
|
|
|
|
snapshot!.StatusCode.ShouldBe(BadTypeMismatch);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------------
|
|
// Robustness: nothing may throw or mutate state off bad input
|
|
// ---------------------------------------------------------------------------------
|
|
|
|
/// <summary>
|
|
/// An undecodable body is not evidence of anything. It must not clear an alias table, advance a
|
|
/// sequence tracker, or evict a birth — a hostile publisher on a group-wide <c>#</c> subscription
|
|
/// would otherwise be able to blank the address space with garbage bytes.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task InvalidPayload_MutatesNothing()
|
|
{
|
|
var publish = new RecordingPublishTransport();
|
|
var ing = await NewSubscribedIngestorAsync(publish);
|
|
FeedBirths(ing);
|
|
publish.Topics.Clear();
|
|
|
|
ing.HandleMessage("spBv1.0/Plant1/DDATA/EdgeA/Filler1", [0xDE, 0xAD, 0xBE, 0xEF], retained: false);
|
|
await ing.DrainPendingRebirthsAsync();
|
|
|
|
// The birth survived …
|
|
ing.Births.Find(new SparkplugScope(Group, Node, Device)).ShouldNotBeNull();
|
|
|
|
// … the sequence baseline survived (seq 2 is still contiguous, so no rebirth) …
|
|
var seen = new Dictionary<string, object?>(StringComparer.Ordinal);
|
|
ing.OnDataChange += (_, e) => seen[e.FullReference] = e.Snapshot.Value;
|
|
ing.Dispatch(Topic(SparkplugMessageType.DDATA, Device), Ddata(seq: 2, (TempAlias, TahuDataType.Float, 9f)));
|
|
await ing.DrainPendingRebirthsAsync();
|
|
|
|
seen[TempPath].ShouldBe(9f);
|
|
publish.Topics.ShouldBeEmpty();
|
|
}
|
|
|
|
/// <summary>An undecodable NDEATH must not kill a live node either.</summary>
|
|
[Fact]
|
|
public async Task InvalidNdeathPayload_DoesNotStale()
|
|
{
|
|
var ing = await NewSubscribedIngestorAsync();
|
|
FeedBirths(ing);
|
|
|
|
var quals = new List<uint>();
|
|
ing.OnDataChange += (_, e) => quals.Add(e.Snapshot.StatusCode);
|
|
|
|
ing.HandleMessage("spBv1.0/Plant1/NDEATH/EdgeA", [0x01, 0x02, 0x03], retained: false);
|
|
|
|
quals.ShouldBeEmpty();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Runs on MQTTnet's dispatcher thread: an escaping exception stalls delivery for every
|
|
/// subscription on the connection.
|
|
/// </summary>
|
|
[Theory]
|
|
[InlineData("")]
|
|
[InlineData("not/a/sparkplug/topic")]
|
|
[InlineData("spBv1.0/Plant1/NBIRTH/EdgeA")]
|
|
[InlineData("spBv1.0/Plant1/DDATA/EdgeA/Filler1")]
|
|
[InlineData("spBv1.0/STATE/otopcua-host-1")]
|
|
[InlineData("spBv1.0")]
|
|
public async Task HandleMessage_NeverThrows(string topic)
|
|
{
|
|
var ing = await NewSubscribedIngestorAsync();
|
|
|
|
Should.NotThrow(() => ing.HandleMessage(topic, [], retained: false));
|
|
Should.NotThrow(() => ing.HandleMessage(topic, [0xFF, 0x00, 0x7F], retained: true));
|
|
}
|
|
|
|
/// <summary>A throwing subscriber is contained rather than escaping onto the dispatcher thread.</summary>
|
|
[Fact]
|
|
public async Task ThrowingSubscriber_IsContained()
|
|
{
|
|
var ing = await NewSubscribedIngestorAsync();
|
|
ing.OnDataChange += (_, _) => throw new InvalidOperationException("boom");
|
|
|
|
Should.NotThrow(() => FeedBirths(ing));
|
|
}
|
|
|
|
/// <summary>
|
|
/// An oversize body is refused BEFORE any protobuf parse, and mutates nothing.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The payload is a <b>genuine, decodable</b> NBIRTH — the control below proves the very same
|
|
/// bytes birth the scope once the ceiling is raised. An earlier version of this test fed
|
|
/// <c>new byte[64]</c>, which fails to parse anyway, so it held with the size check deleted.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task OversizePayload_IsDroppedBeforeDecoding()
|
|
{
|
|
var wire = NbirthBytes(seq: 0, bdSeq: 1);
|
|
var ing = NewIngestor(maxPayloadBytes: wire.Length - 1);
|
|
await ing.SubscribeAsync([TempPath], TimeSpan.Zero, TestContext.Current.CancellationToken);
|
|
|
|
ing.HandleMessage("spBv1.0/Plant1/NBIRTH/EdgeA", wire, retained: false);
|
|
|
|
ing.Births.Count.ShouldBe(0);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Falsifiability control for the test above: the identical bytes, one byte of headroom, birth
|
|
/// the scope. Without this the oversize assertion is satisfied by any reason the payload failed.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task PayloadExactlyAtTheCeiling_IsAccepted()
|
|
{
|
|
var wire = NbirthBytes(seq: 0, bdSeq: 1);
|
|
var ing = NewIngestor(maxPayloadBytes: wire.Length);
|
|
await ing.SubscribeAsync([TempPath], TimeSpan.Zero, TestContext.Current.CancellationToken);
|
|
|
|
ing.HandleMessage("spBv1.0/Plant1/NBIRTH/EdgeA", wire, retained: false);
|
|
|
|
ing.Births.Count.ShouldBe(1);
|
|
}
|
|
|
|
/// <summary>
|
|
/// The driver subscribes <c>spBv1.0/{group}/#</c>, so a large plant delivers traffic for every
|
|
/// node in the group. Only authored edge nodes are processed — that is what bounds the tracker
|
|
/// and birth maps by configuration rather than by whatever the plant publishes.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task UnauthoredEdgeNode_IsIgnoredEntirely()
|
|
{
|
|
var publish = new RecordingPublishTransport();
|
|
var ing = await NewSubscribedIngestorAsync(publish);
|
|
|
|
var fired = false;
|
|
ing.OnDataChange += (_, _) => fired = true;
|
|
|
|
ing.Dispatch(new SparkplugTopic(SparkplugMessageType.NBIRTH, Group, "StrangerNode", null, null), Nbirth(seq: 0, bdSeq: 1));
|
|
ing.Dispatch(
|
|
new SparkplugTopic(SparkplugMessageType.DDATA, Group, "StrangerNode", "Dev", null),
|
|
Ddata(seq: 1, (TempAlias, TahuDataType.Float, 1f)));
|
|
await ing.DrainPendingRebirthsAsync();
|
|
|
|
fired.ShouldBeFalse();
|
|
ing.Births.Count.ShouldBe(0);
|
|
publish.Topics.ShouldBeEmpty();
|
|
}
|
|
|
|
/// <summary>
|
|
/// An unauthored DEVICE under an authored node still advances its edge node's sequence — dropping
|
|
/// it early would manufacture a gap on the very next authored message — and, because nothing in
|
|
/// that scope could ever be published, it must not demand a rebirth either. A spec-violating
|
|
/// device that publishes DDATA without ever DBIRTHing would otherwise drive a permanent NCMD at
|
|
/// the debounce cadence, forever.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task UnauthoredDeviceUnderAnAuthoredNode_StillAdvancesTheSequence()
|
|
{
|
|
var publish = new RecordingPublishTransport();
|
|
var ing = await NewSubscribedIngestorAsync(publish);
|
|
FeedBirths(ing);
|
|
publish.Topics.Clear();
|
|
|
|
// seq 2 belongs to a device nobody authored …
|
|
ing.Dispatch(
|
|
new SparkplugTopic(SparkplugMessageType.DDATA, Group, Node, "OtherDevice", null),
|
|
Ddata(seq: 2, (TempAlias, TahuDataType.Float, 1f)));
|
|
|
|
// … and seq 3 must therefore still be contiguous.
|
|
ing.Dispatch(Topic(SparkplugMessageType.DDATA, Device), Ddata(seq: 3, (TempAlias, TahuDataType.Float, 2f)));
|
|
await ing.DrainPendingRebirthsAsync();
|
|
|
|
publish.Topics.ShouldBeEmpty();
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------------
|
|
// Registration / resolution / read
|
|
// ---------------------------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public void Register_MapsOnlySparkplugShapedBlobs()
|
|
{
|
|
var ing = NewIngestor(tags: []);
|
|
|
|
var mapped = ing.Register([
|
|
Tag(TempPath, Blob(Group, Node, Device, "Temperature", "Float32")),
|
|
Tag("Plant/Mqtt/spb/Plainish", """{"topic":"f/oven/temp","payloadFormat":"Scalar","dataType":"Float64"}"""),
|
|
Tag("Plant/Mqtt/spb/NoMetric", """{"groupId":"Plant1","edgeNodeId":"EdgeA"}"""),
|
|
]);
|
|
|
|
mapped.ShouldBe(1);
|
|
ing.TryResolve(TempPath, out var def).ShouldBeTrue();
|
|
def.MetricName.ShouldBe("Temperature");
|
|
def.Name.ShouldBe(TempPath);
|
|
}
|
|
|
|
[Fact]
|
|
public void Register_IsWholesale_ADroppedTagStopsResolving()
|
|
{
|
|
var ing = NewIngestor();
|
|
ing.TryResolve(TempPath, out _).ShouldBeTrue();
|
|
|
|
ing.Register([]);
|
|
|
|
ing.TryResolve(TempPath, out _).ShouldBeFalse();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SubscribeAsync_UnauthoredReference_ReportsBadNodeIdUnknown()
|
|
{
|
|
var ing = NewIngestor();
|
|
|
|
await ing.SubscribeAsync(["Plant/Mqtt/spb/NotAThing"], TimeSpan.Zero, TestContext.Current.CancellationToken);
|
|
|
|
ing.Values.Read("Plant/Mqtt/spb/NotAThing").StatusCode.ShouldBe(BadNodeIdUnknown);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UnsubscribedButAuthoredTag_IsCachedButNotRaised()
|
|
{
|
|
var ing = NewIngestor();
|
|
var fired = false;
|
|
ing.OnDataChange += (_, _) => fired = true;
|
|
|
|
FeedBirths(ing);
|
|
|
|
fired.ShouldBeFalse();
|
|
ing.Values.Read(TempPath).Value.ShouldBe(0f);
|
|
}
|
|
|
|
/// <summary>Two raw tags may legitimately project the same Sparkplug metric; both must be fed.</summary>
|
|
[Fact]
|
|
public async Task TwoTagsBoundToOneMetric_BothReceive()
|
|
{
|
|
const string Mirror = "Plant/Mqtt/spb/Filler1TempMirror";
|
|
var ing = NewIngestor(extraTags: [Tag(Mirror, Blob(Group, Node, Device, "Temperature", "Float32"))]);
|
|
await ing.SubscribeAsync([TempPath, Mirror], TimeSpan.Zero, TestContext.Current.CancellationToken);
|
|
|
|
var seen = new List<string>();
|
|
ing.OnDataChange += (_, e) => seen.Add(e.FullReference);
|
|
|
|
FeedBirths(ing);
|
|
ing.Dispatch(Topic(SparkplugMessageType.DDATA, Device), Ddata(seq: 2, (TempAlias, TahuDataType.Float, 1f)));
|
|
|
|
seen.ShouldContain(TempPath);
|
|
seen.ShouldContain(Mirror);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------------
|
|
// Subscribe / reconnect
|
|
// ---------------------------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public async Task EstablishAsync_SubscribesTheGroupAndStateFilters_AndRequestsLateJoinRebirth()
|
|
{
|
|
var subscribe = new FakeSubscribeTransport();
|
|
var publish = new RecordingPublishTransport();
|
|
var ing = NewIngestor(publish, subscribe, hostId: "otopcua-host-1");
|
|
|
|
await ing.EstablishAsync(TestContext.Current.CancellationToken);
|
|
await ing.DrainPendingRebirthsAsync();
|
|
|
|
subscribe.Filters.Select(f => f.Topic).ShouldBe(
|
|
["spBv1.0/Plant1/#", "spBv1.0/STATE/otopcua-host-1", "STATE/otopcua-host-1"]);
|
|
publish.Topics.ShouldContain("spBv1.0/Plant1/NCMD/EdgeA");
|
|
}
|
|
|
|
/// <summary>No host id ⇒ no STATE filter. This driver has no use for a stranger's host state.</summary>
|
|
[Fact]
|
|
public async Task EstablishAsync_WithNoHostId_SubscribesTheGroupFilterOnly()
|
|
{
|
|
var subscribe = new FakeSubscribeTransport();
|
|
var ing = NewIngestor(subscribe: subscribe, hostId: "");
|
|
|
|
await ing.EstablishAsync(TestContext.Current.CancellationToken);
|
|
|
|
subscribe.Filters.Select(f => f.Topic).ShouldBe(["spBv1.0/Plant1/#"]);
|
|
}
|
|
|
|
/// <summary>
|
|
/// There is exactly one filter and it covers every tag, so a broker that refuses it leaves the
|
|
/// whole driver deaf — the one outcome that must be a visible fault rather than a healthy-looking
|
|
/// driver receiving nothing.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task EstablishAsync_GroupFilterRefused_Throws()
|
|
{
|
|
var subscribe = new FakeSubscribeTransport { Reject = _ => true };
|
|
var ing = NewIngestor(subscribe: subscribe);
|
|
|
|
await Should.ThrowAsync<InvalidOperationException>(
|
|
() => ing.EstablishAsync(TestContext.Current.CancellationToken));
|
|
}
|
|
|
|
/// <summary>A refused STATE filter costs an observability signal, not the data plane.</summary>
|
|
[Fact]
|
|
public async Task EstablishAsync_StateFilterRefused_DoesNotThrow()
|
|
{
|
|
var subscribe = new FakeSubscribeTransport { Reject = t => t.Contains("STATE", StringComparison.Ordinal) };
|
|
var ing = NewIngestor(subscribe: subscribe, hostId: "otopcua-host-1");
|
|
|
|
await ing.EstablishAsync(TestContext.Current.CancellationToken);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Across an outage of unknown length an edge node may have restarted and rebound its aliases; a
|
|
/// DDATA resolved against the pre-outage table would route a value to whichever tag used to own
|
|
/// that alias.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task OnReconnected_ClearsBirths_ResubscribesAndRequestsRebirth()
|
|
{
|
|
var subscribe = new FakeSubscribeTransport();
|
|
var publish = new RecordingPublishTransport();
|
|
var ing = NewIngestor(publish, subscribe);
|
|
await ing.SubscribeAsync([TempPath], TimeSpan.Zero, TestContext.Current.CancellationToken);
|
|
|
|
FeedBirths(ing);
|
|
ing.Births.Count.ShouldBeGreaterThan(0);
|
|
publish.Topics.Clear();
|
|
subscribe.Filters.Clear();
|
|
|
|
await ing.OnReconnectedAsync(TestContext.Current.CancellationToken);
|
|
await ing.DrainPendingRebirthsAsync();
|
|
|
|
ing.Births.Count.ShouldBe(0);
|
|
subscribe.Filters.Select(f => f.Topic).ShouldContain("spBv1.0/Plant1/#");
|
|
publish.Topics.ShouldContain("spBv1.0/Plant1/NCMD/EdgeA");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Reconnect is a driver-side event, not a statement about the plant: last-known values remain
|
|
/// the best available answer until the rebirth lands. Only a death stales a tag.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task OnReconnected_DoesNotStaleValues()
|
|
{
|
|
var ing = await NewSubscribedIngestorAsync(new RecordingPublishTransport(), new FakeSubscribeTransport());
|
|
FeedBirths(ing);
|
|
|
|
await ing.OnReconnectedAsync(TestContext.Current.CancellationToken);
|
|
|
|
ing.Values.Read(TempPath).StatusCode.ShouldBe(Good);
|
|
}
|
|
|
|
/// <summary>After a reconnect the driver has no birth, so the first DDATA must ask for one.</summary>
|
|
[Fact]
|
|
public async Task AfterReconnect_DataResolvesAgainstNothing_AndRequestsRebirth()
|
|
{
|
|
var publish = new RecordingPublishTransport();
|
|
var ing = await NewSubscribedIngestorAsync(publish, new FakeSubscribeTransport(), rebirthDebounce: TimeSpan.Zero);
|
|
FeedBirths(ing);
|
|
|
|
await ing.OnReconnectedAsync(TestContext.Current.CancellationToken);
|
|
await ing.DrainPendingRebirthsAsync();
|
|
publish.Topics.Clear();
|
|
|
|
var fired = false;
|
|
ing.OnDataChange += (_, _) => fired = true;
|
|
ing.Dispatch(Topic(SparkplugMessageType.DDATA, Device), Ddata(seq: 2, (TempAlias, TahuDataType.Float, 1f)));
|
|
await ing.DrainPendingRebirthsAsync();
|
|
|
|
fired.ShouldBeFalse();
|
|
publish.Topics.ShouldContain("spBv1.0/Plant1/NCMD/EdgeA");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------------
|
|
// STATE (receive only — this driver never publishes one; see the ingestor's remarks)
|
|
// ---------------------------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public async Task State_V30JsonPayload_IsObserved()
|
|
{
|
|
var ing = await NewSubscribedIngestorAsync();
|
|
|
|
ing.HandleMessage("spBv1.0/STATE/otopcua-host-1", """{"online":true,"timestamp":1721822400000}"""u8, retained: true);
|
|
|
|
ing.HostState!.HostId.ShouldBe("otopcua-host-1");
|
|
ing.HostState!.Online.ShouldBeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task State_LegacyTextPayload_IsObserved()
|
|
{
|
|
var ing = await NewSubscribedIngestorAsync();
|
|
|
|
ing.HandleMessage("STATE/otopcua-host-1", "OFFLINE"u8, retained: true);
|
|
|
|
ing.HostState!.Online.ShouldBeFalse();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task State_UnrecognisedPayload_IsIgnored()
|
|
{
|
|
var ing = await NewSubscribedIngestorAsync();
|
|
|
|
ing.HandleMessage("spBv1.0/STATE/otopcua-host-1", "maybe?"u8, retained: true);
|
|
|
|
ing.HostState.ShouldBeNull();
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------------
|
|
// Task 22 seam
|
|
// ---------------------------------------------------------------------------------
|
|
|
|
/// <summary>
|
|
/// The rediscovery seam Task 22 wires to <c>IRediscoverable.OnRediscoveryNeeded</c>. Deliberately
|
|
/// unwired in <c>MqttDriver</c> today — this asserts the event exists and carries the scope +
|
|
/// metric set that Task 22 needs to decide whether the tree changed.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task BirthObserved_FiresWithTheScopeAndMetricNames()
|
|
{
|
|
var ing = await NewSubscribedIngestorAsync();
|
|
var observed = new List<SparkplugBirthObservedEventArgs>();
|
|
ing.BirthObserved += (_, e) => observed.Add(e);
|
|
|
|
FeedBirths(ing);
|
|
|
|
observed.Count.ShouldBe(2);
|
|
observed[0].Scope.ShouldBe(new SparkplugScope(Group, Node, null));
|
|
observed[1].Scope.ShouldBe(new SparkplugScope(Group, Node, Device));
|
|
observed[1].MetricNames.ShouldContain("Temperature");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------------
|
|
// Session-state reset — the seams where the view underneath a surviving cache changes
|
|
// ---------------------------------------------------------------------------------
|
|
|
|
/// <summary>
|
|
/// <b>C1.</b> The ingestor OUTLIVES a session rebuild: <c>SessionIdentity</c> is a strict
|
|
/// superset of <c>IngestIdentity</c>, so changing only the broker Host / Port / ClientId /
|
|
/// credentials / TLS / a timeout re-establishes on this very instance. If the birth cache
|
|
/// survived that, an edge node which restarted during the changeover and rebound alias 5 from
|
|
/// Temperature to Pressure would have its first DDATA on the NEW session resolved against the
|
|
/// OLD table — Pressure's value under the Temperature RawPath, at Good quality.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task EstablishAsync_DropsBirthsFromThePreviousSession()
|
|
{
|
|
var subscribe = new FakeSubscribeTransport();
|
|
var ing = await NewSubscribedIngestorAsync(new RecordingPublishTransport(), subscribe);
|
|
FeedBirths(ing);
|
|
ing.Births.Find(new SparkplugScope(Group, Node, Device)).ShouldNotBeNull();
|
|
|
|
await ing.EstablishAsync(TestContext.Current.CancellationToken);
|
|
|
|
ing.Births.Find(new SparkplugScope(Group, Node, Device)).ShouldBeNull();
|
|
}
|
|
|
|
/// <summary>
|
|
/// The consequence, asserted on the wire rather than on the cache: after a session rebuild the
|
|
/// old alias must not resolve, so the first DDATA publishes nothing and asks for a rebirth.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task AfterEstablish_OldAliasNoLongerResolves()
|
|
{
|
|
var publish = new RecordingPublishTransport();
|
|
var ing = await NewSubscribedIngestorAsync(publish, new FakeSubscribeTransport());
|
|
FeedBirths(ing);
|
|
|
|
await ing.EstablishAsync(TestContext.Current.CancellationToken);
|
|
|
|
// Drain BEFORE clearing: Establish's own late-join rebirth is dispatched off-thread, so a bare
|
|
// Clear() races it and the assertions below could be satisfied by that NCMD rather than by the
|
|
// one the reset is supposed to cause.
|
|
await ing.DrainPendingRebirthsAsync();
|
|
publish.Topics.Clear();
|
|
|
|
var fired = false;
|
|
ing.OnDataChange += (_, _) => fired = true;
|
|
ing.Dispatch(Topic(SparkplugMessageType.DDATA, Device), Ddata(seq: 2, (TempAlias, TahuDataType.Float, 1f)));
|
|
await ing.DrainPendingRebirthsAsync();
|
|
|
|
fired.ShouldBeFalse();
|
|
publish.Topics.ShouldContain("spBv1.0/Plant1/NCMD/EdgeA");
|
|
}
|
|
|
|
/// <summary>
|
|
/// The sequence half of the same reset. A surviving baseline lets a fresh stream look contiguous
|
|
/// by coincidence — and, worse, leaves <c>IsBirthSynchronized</c> true, so the late-join branch
|
|
/// never fires and nothing asks for the metadata the driver no longer has.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task EstablishAsync_DropsTheSequenceBaseline()
|
|
{
|
|
var publish = new RecordingPublishTransport();
|
|
var ing = await NewSubscribedIngestorAsync(publish, new FakeSubscribeTransport());
|
|
FeedBirths(ing);
|
|
|
|
await ing.EstablishAsync(TestContext.Current.CancellationToken);
|
|
|
|
// Drain BEFORE clearing: Establish's own late-join rebirth is dispatched off-thread, so a bare
|
|
// Clear() races it and the assertions below could be satisfied by that NCMD rather than by the
|
|
// one the reset is supposed to cause.
|
|
await ing.DrainPendingRebirthsAsync();
|
|
publish.Topics.Clear();
|
|
|
|
// Contiguous against the OLD baseline (the DBIRTH was seq 1) — must still be treated as a late
|
|
// join, because nothing about the new session's stream has been observed.
|
|
ing.Dispatch(Topic(SparkplugMessageType.DDATA, Device), Ddata(seq: 2, (TempAlias, TahuDataType.Float, 1f)));
|
|
await ing.DrainPendingRebirthsAsync();
|
|
|
|
publish.Topics.ShouldContain("spBv1.0/Plant1/NCMD/EdgeA");
|
|
}
|
|
|
|
/// <summary>
|
|
/// <c>AttachTo</c> is the earliest seam at which the session changes, and a persistent-session
|
|
/// broker can push queued messages between CONNACK and our own SUBSCRIBE — so the reset happens
|
|
/// there too, not only at <c>EstablishAsync</c>.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task AttachTo_DropsBirthsFromThePreviousSession()
|
|
{
|
|
var ing = await NewSubscribedIngestorAsync();
|
|
FeedBirths(ing);
|
|
|
|
await using var connection = new MqttConnection(
|
|
new MqttDriverOptions { Host = "127.0.0.1", Port = 1, UseTls = false },
|
|
"mqtt-spb-1");
|
|
ing.AttachTo(connection);
|
|
|
|
ing.Births.Count.ShouldBe(0);
|
|
}
|
|
|
|
/// <summary>
|
|
/// <b>C2.</b> While an edge node is unauthored its traffic is dropped at <c>Dispatch</c>, so its
|
|
/// cached birth FREEZES rather than refreshing. Deploy A authors it, deploy B drops it, deploy C
|
|
/// re-adds it a week later having missed every rebirth in between — a surviving cache answers
|
|
/// with the week-old alias table and the next DDATA mis-routes at Good quality.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Register_DroppingAnEdgeNode_EvictsItsBirths()
|
|
{
|
|
var ing = await NewSubscribedIngestorAsync();
|
|
FeedBirths(ing);
|
|
ing.Births.Count.ShouldBeGreaterThan(0);
|
|
|
|
ing.Register([]); // deploy B: the node is no longer authored
|
|
ing.Register(DefaultTags()); // deploy C: it comes back
|
|
|
|
ing.Births.Count.ShouldBe(0);
|
|
}
|
|
|
|
/// <summary>The C2 consequence on the wire: the re-added node's old alias must not resolve.</summary>
|
|
[Fact]
|
|
public async Task AfterEdgeNodeLeftAndReturned_OldAliasNoLongerResolves()
|
|
{
|
|
var publish = new RecordingPublishTransport();
|
|
var ing = await NewSubscribedIngestorAsync(publish);
|
|
FeedBirths(ing);
|
|
|
|
ing.Register([]);
|
|
ing.Register(DefaultTags());
|
|
await ing.SubscribeAsync(
|
|
[.. ing.AuthoredRawPaths],
|
|
TimeSpan.Zero,
|
|
TestContext.Current.CancellationToken);
|
|
publish.Topics.Clear();
|
|
|
|
var fired = false;
|
|
ing.OnDataChange += (_, _) => fired = true;
|
|
ing.Dispatch(Topic(SparkplugMessageType.DDATA, Device), Ddata(seq: 2, (TempAlias, TahuDataType.Float, 1f)));
|
|
await ing.DrainPendingRebirthsAsync();
|
|
|
|
fired.ShouldBeFalse();
|
|
publish.Topics.ShouldContain("spBv1.0/Plant1/NCMD/EdgeA");
|
|
}
|
|
|
|
/// <summary>
|
|
/// The narrower half of the C2 rule, asserted so a future "tidy-up" cannot widen it. A device
|
|
/// with no authored tags whose EDGE NODE is still authored keeps receiving traffic (the Dispatch
|
|
/// filter is node-level), so its birth is live, not frozen — evicting it would throw away
|
|
/// correct state and cost a rebirth round-trip the moment a later deploy authored a tag on it.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Register_KeepsBirthsForStillAuthoredEdgeNodes()
|
|
{
|
|
var ing = await NewSubscribedIngestorAsync();
|
|
FeedBirths(ing);
|
|
|
|
// Drops the Filler1 tags but keeps the node-level Uptime tag, so EdgeA stays authored.
|
|
ing.Register([Tag(NodeUptimePath, Blob(Group, Node, null, "Uptime", "Int64"))]);
|
|
|
|
ing.Births.Find(new SparkplugScope(Group, Node, Device)).ShouldNotBeNull();
|
|
ing.Births.Find(new SparkplugScope(Group, Node, null)).ShouldNotBeNull();
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------------
|
|
// Rebirth-loop bounds
|
|
// ---------------------------------------------------------------------------------
|
|
|
|
/// <summary>
|
|
/// An NBIRTH whose <c>seq</c> cannot be read leaves the tracker un-synchronised forever — asking
|
|
/// for another birth cannot fix it, because the node answers with the same unusable payload. The
|
|
/// driver must record that a birth ARRIVED and stop asking.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task NbirthWithUnreadableSeq_DoesNotLoopRequestingRebirths()
|
|
{
|
|
var publish = new RecordingPublishTransport();
|
|
var ing = await NewSubscribedIngestorAsync(publish);
|
|
|
|
// seq is present but out of the 0-255 Sparkplug range, so AcceptNodeBirth refuses it.
|
|
var payload = new Payload { Seq = 9999UL, Timestamp = 1UL };
|
|
payload.Metrics.Add(BirthMetric("Uptime", 7UL, TahuDataType.Int64, 0L));
|
|
ing.Dispatch(Topic(SparkplugMessageType.NBIRTH), Decode(payload));
|
|
publish.Topics.Clear();
|
|
|
|
for (var i = 0; i < 5; i++)
|
|
{
|
|
ing.Dispatch(Topic(SparkplugMessageType.DBIRTH, Device), Dbirth(seq: (ulong)i));
|
|
}
|
|
|
|
await ing.DrainPendingRebirthsAsync();
|
|
|
|
publish.Topics.ShouldBeEmpty();
|
|
}
|
|
|
|
/// <summary>
|
|
/// The residual loop the birth-seen flag cannot see: an NBIRTH that never reaches the state
|
|
/// machine — because it exceeded the payload ceiling, or because the node simply never sends
|
|
/// one. Nothing about asking again changes that.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Each of these messages raises <b>two</b> uncapped conditions in the pre-fix code — late join
|
|
/// AND data-before-birth — which is what made the first version of this test observe 23 NCMDs
|
|
/// against a cap of 3. The debounce caps the RATE, not the LIFETIME; only the per-node
|
|
/// consecutive cap ends the loop, and it has to cover every missing-metadata reason to do it.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task NbirthNeverArriving_CapsConsecutiveMetadataRebirths()
|
|
{
|
|
var publish = new RecordingPublishTransport();
|
|
var ing = await NewSubscribedIngestorAsync(publish);
|
|
|
|
for (var i = 0; i < 20; i++)
|
|
{
|
|
ing.Dispatch(Topic(SparkplugMessageType.DDATA, Device), Ddata(seq: (ulong)i, (TempAlias, TahuDataType.Float, 1f)));
|
|
}
|
|
|
|
await ing.DrainPendingRebirthsAsync();
|
|
|
|
// Bounded, and bounded LOW — the cap, not "eventually stops".
|
|
publish.Topics.Count.ShouldBeLessThanOrEqualTo(3);
|
|
publish.Topics.ShouldNotBeEmpty();
|
|
}
|
|
|
|
/// <summary>A birth that DOES arrive re-arms the cap — the count is consecutive, not cumulative.</summary>
|
|
[Fact]
|
|
public async Task ArrivingNbirth_ResetsTheMetadataRebirthCap()
|
|
{
|
|
var publish = new RecordingPublishTransport();
|
|
var ing = await NewSubscribedIngestorAsync(publish);
|
|
|
|
for (var i = 0; i < 20; i++)
|
|
{
|
|
ing.Dispatch(Topic(SparkplugMessageType.DDATA, Device), Ddata(seq: (ulong)i, (TempAlias, TahuDataType.Float, 1f)));
|
|
}
|
|
|
|
await ing.OnReconnectedAsync(TestContext.Current.CancellationToken);
|
|
await ing.DrainPendingRebirthsAsync();
|
|
publish.Topics.Clear();
|
|
|
|
ing.Dispatch(Topic(SparkplugMessageType.DDATA, Device), Ddata(seq: 0, (TempAlias, TahuDataType.Float, 1f)));
|
|
await ing.DrainPendingRebirthsAsync();
|
|
|
|
publish.Topics.ShouldNotBeEmpty();
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------------
|
|
// Value-level gates
|
|
// ---------------------------------------------------------------------------------
|
|
|
|
/// <summary>
|
|
/// Array metrics slip past the unsupported-type gate, because <c>ToDriverDataType</c> maps every
|
|
/// <c>*Array</c> variant to its ELEMENT type and is therefore never null for one. The value
|
|
/// rides as a packed <c>byte[]</c>, so a String-typed tag would publish base64 at GOOD quality.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task ArrayMetric_IsSkipped_NotPublishedAsBase64()
|
|
{
|
|
const string ArrTag = "Plant/Mqtt/spb/TempArray";
|
|
var ing = NewIngestor(tags: [Tag(ArrTag, Blob(Group, Node, Device, "Temperature", "String"))]);
|
|
await ing.SubscribeAsync([ArrTag], TimeSpan.Zero, TestContext.Current.CancellationToken);
|
|
|
|
var seen = new List<DataValueSnapshot>();
|
|
ing.OnDataChange += (_, e) => seen.Add(e.Snapshot);
|
|
|
|
ing.Dispatch(Topic(SparkplugMessageType.NBIRTH), Nbirth(seq: 0, bdSeq: 1));
|
|
|
|
var birth = new Payload { Seq = 1UL, Timestamp = 1UL };
|
|
birth.Metrics.Add(new Payload.Types.Metric
|
|
{
|
|
Name = "Temperature",
|
|
Alias = TempAlias,
|
|
Datatype = (uint)TahuDataType.FloatArray,
|
|
BytesValue = ByteString.CopyFrom([0x00, 0x00, 0x80, 0x3F]),
|
|
});
|
|
ing.Dispatch(Topic(SparkplugMessageType.DBIRTH, Device), Decode(birth));
|
|
|
|
seen.ShouldBeEmpty();
|
|
ing.Values.Read(ArrTag).StatusCode.ShouldNotBe(Good);
|
|
}
|
|
|
|
/// <summary>
|
|
/// <c>Convert.ToSingle</c> saturates rather than throwing, so a Double of 1e300 onto a Float32
|
|
/// tag would publish +Infinity at GOOD quality — a number no gauge renders and no comparison
|
|
/// handles. A narrowing overflow is a refusal.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task NarrowingOverflowToFloat32_IsRefused_NotPublishedAsInfinity()
|
|
{
|
|
const string F32 = "Plant/Mqtt/spb/Narrowed";
|
|
var ing = NewIngestor(tags: [Tag(F32, Blob(Group, Node, Device, "Big", "Float32"))]);
|
|
await ing.SubscribeAsync([F32], TimeSpan.Zero, TestContext.Current.CancellationToken);
|
|
|
|
DataValueSnapshot? snapshot = null;
|
|
ing.OnDataChange += (_, e) => snapshot = e.Snapshot;
|
|
|
|
ing.Dispatch(Topic(SparkplugMessageType.NBIRTH), Nbirth(seq: 0, bdSeq: 1));
|
|
ing.Dispatch(
|
|
Topic(SparkplugMessageType.DBIRTH, Device),
|
|
DbirthCustom(seq: 1, ("Big", 21UL, TahuDataType.Double, 1e300d)));
|
|
|
|
snapshot!.StatusCode.ShouldBe(BadTypeMismatch);
|
|
snapshot.Value.ShouldBeNull();
|
|
}
|
|
|
|
/// <summary>
|
|
/// A publisher's OWN infinity is its value, not our overflow — it passes through, so the guard
|
|
/// above cannot be satisfied by blanket-refusing non-finite numbers.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task PublishersOwnInfinity_IsPassedThrough()
|
|
{
|
|
const string F32 = "Plant/Mqtt/spb/Inf";
|
|
var ing = NewIngestor(tags: [Tag(F32, Blob(Group, Node, Device, "Big", "Float32"))]);
|
|
await ing.SubscribeAsync([F32], TimeSpan.Zero, TestContext.Current.CancellationToken);
|
|
|
|
DataValueSnapshot? snapshot = null;
|
|
ing.OnDataChange += (_, e) => snapshot = e.Snapshot;
|
|
|
|
ing.Dispatch(Topic(SparkplugMessageType.NBIRTH), Nbirth(seq: 0, bdSeq: 1));
|
|
ing.Dispatch(
|
|
Topic(SparkplugMessageType.DBIRTH, Device),
|
|
DbirthCustom(seq: 1, ("Big", 21UL, TahuDataType.Float, float.PositiveInfinity)));
|
|
|
|
snapshot!.StatusCode.ShouldBe(Good);
|
|
snapshot.Value.ShouldBe(float.PositiveInfinity);
|
|
}
|
|
|
|
/// <summary>
|
|
/// The metric NAME is the binding key, so when a DATA metric carries both a name and an alias
|
|
/// that disagree, the name wins. Preferring the alias resolves to whichever metric currently
|
|
/// occupies that slot — the mis-route the binding rule exists to prevent.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task DataMetricCarryingNameAndDisagreeingAlias_BindsByName()
|
|
{
|
|
var ing = await NewSubscribedIngestorAsync();
|
|
FeedBirths(ing);
|
|
|
|
var seen = new Dictionary<string, object?>(StringComparer.Ordinal);
|
|
ing.OnDataChange += (_, e) => seen[e.FullReference] = e.Snapshot.Value;
|
|
|
|
// Alias 6 is Pressure; the name says Temperature. The name is the identity that survives a
|
|
// rebirth, so this must land on the Temperature RawPath.
|
|
var payload = new Payload { Seq = 2UL, Timestamp = 1UL };
|
|
payload.Metrics.Add(new Payload.Types.Metric
|
|
{
|
|
Name = "Temperature",
|
|
Alias = PressAlias,
|
|
FloatValue = 3.5f,
|
|
});
|
|
ing.Dispatch(Topic(SparkplugMessageType.DDATA, Device), Decode(payload));
|
|
|
|
seen.ShouldContainKey(TempPath);
|
|
seen[TempPath].ShouldBe(3.5f);
|
|
seen.ShouldNotContainKey(PressPath);
|
|
}
|
|
|
|
/// <summary>
|
|
/// The legacy pre-3.0 STATE form is parsed on receive — so it must also be SUBSCRIBED, or that
|
|
/// tolerance is reachable only from a test calling <c>HandleMessage</c> directly.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task EstablishAsync_SubscribesTheLegacyStateFilterToo()
|
|
{
|
|
var subscribe = new FakeSubscribeTransport();
|
|
var ing = NewIngestor(subscribe: subscribe, hostId: "otopcua-host-1");
|
|
|
|
await ing.EstablishAsync(TestContext.Current.CancellationToken);
|
|
|
|
subscribe.Filters.Select(f => f.Topic).ShouldContain("STATE/otopcua-host-1");
|
|
}
|
|
|
|
// =================================================================================
|
|
// 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}",""";
|
|
var typeKey = dataType is null ? "" : $""","dataType":"{dataType}" """;
|
|
return $$"""{"groupId":"{{group}}","edgeNodeId":"{{node}}",{{deviceKey}}"metricName":"{{metric}}"{{typeKey}}}""";
|
|
}
|
|
|
|
/// <summary>The standard authored set: three device metrics plus one node-level metric.</summary>
|
|
private static RawTagEntry[] DefaultTags() =>
|
|
[
|
|
Tag(TempPath, Blob(Group, Node, Device, "Temperature", "Float32")),
|
|
Tag(PressPath, Blob(Group, Node, Device, "Pressure", "Float32")),
|
|
Tag(CountPath, Blob(Group, Node, Device, "Count", "Int32")),
|
|
Tag(NodeUptimePath, Blob(Group, Node, null, "Uptime", "Int64")),
|
|
];
|
|
|
|
private static SparkplugIngestor NewIngestor(
|
|
RecordingPublishTransport? publish = null,
|
|
FakeSubscribeTransport? subscribe = null,
|
|
string hostId = "otopcua-host-1",
|
|
bool requestRebirthOnGap = true,
|
|
TimeSpan? rebirthDebounce = null,
|
|
int maxPayloadBytes = MqttSubscriptionManager.DefaultMaxPayloadBytes,
|
|
RawTagEntry[]? tags = null,
|
|
RawTagEntry[]? extraTags = null)
|
|
{
|
|
var options = new MqttDriverOptions
|
|
{
|
|
Mode = MqttMode.SparkplugB,
|
|
Sparkplug = new MqttSparkplugOptions
|
|
{
|
|
GroupId = Group,
|
|
HostId = hostId,
|
|
RequestRebirthOnGap = requestRebirthOnGap,
|
|
},
|
|
};
|
|
|
|
var ingestor = new SparkplugIngestor(
|
|
options,
|
|
"mqtt-spb-1",
|
|
subscribe,
|
|
publish,
|
|
logger: null,
|
|
cache: null,
|
|
maxPayloadBytes: maxPayloadBytes,
|
|
// Zero by default so a test asserting "these two triggers each produce an NCMD" is not
|
|
// silently satisfied by the production debounce; the debounce itself has its own tests.
|
|
rebirthDebounce: rebirthDebounce ?? TimeSpan.Zero);
|
|
|
|
ingestor.Register([.. tags ?? DefaultTags(), .. extraTags ?? []]);
|
|
return ingestor;
|
|
}
|
|
|
|
/// <summary>An ingestor with every default tag registered AND subscribed, so notifications fire.</summary>
|
|
private static async Task<SparkplugIngestor> NewSubscribedIngestorAsync(
|
|
RecordingPublishTransport? publish = null,
|
|
FakeSubscribeTransport? subscribe = null,
|
|
bool requestRebirthOnGap = true,
|
|
TimeSpan? rebirthDebounce = null,
|
|
RawTagEntry[]? extraTags = null)
|
|
{
|
|
var ingestor = NewIngestor(
|
|
publish,
|
|
subscribe,
|
|
requestRebirthOnGap: requestRebirthOnGap,
|
|
rebirthDebounce: rebirthDebounce,
|
|
extraTags: extraTags);
|
|
|
|
await ingestor.SubscribeAsync(
|
|
[.. ingestor.AuthoredRawPaths],
|
|
TimeSpan.Zero,
|
|
TestContext.Current.CancellationToken);
|
|
|
|
return ingestor;
|
|
}
|
|
|
|
/// <summary>Feeds the standard NBIRTH (seq 0, bdSeq 1) + DBIRTH (seq 1) pair.</summary>
|
|
private static void FeedBirths(SparkplugIngestor ing)
|
|
{
|
|
ing.Dispatch(Topic(SparkplugMessageType.NBIRTH), Nbirth(seq: 0, bdSeq: 1));
|
|
ing.Dispatch(Topic(SparkplugMessageType.DBIRTH, Device), Dbirth(seq: 1));
|
|
}
|
|
|
|
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());
|
|
|
|
/// <summary>The standard NBIRTH as WIRE BYTES — for the payload-ceiling tests, which need a real one.</summary>
|
|
private static byte[] NbirthBytes(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,
|
|
});
|
|
payload.Metrics.Add(BirthMetric("Uptime", 7UL, TahuDataType.Int64, 0L));
|
|
return payload.ToByteArray();
|
|
}
|
|
|
|
/// <summary>An NBIRTH: the <c>bdSeq</c> session token plus the node's own metric catalog.</summary>
|
|
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,
|
|
});
|
|
payload.Metrics.Add(BirthMetric("Uptime", 7UL, TahuDataType.Int64, 0L));
|
|
return Decode(payload);
|
|
}
|
|
|
|
/// <summary>The standard DBIRTH: Temperature@5 (Float), Pressure@6 (Float), Count@9 (Int32, -42).</summary>
|
|
private static SparkplugPayload Dbirth(ulong seq) => DbirthCustom(
|
|
seq,
|
|
("Temperature", TempAlias, TahuDataType.Float, 0f),
|
|
("Pressure", PressAlias, TahuDataType.Float, 0f),
|
|
("Count", CountAlias, TahuDataType.Int32, -42));
|
|
|
|
private static SparkplugPayload DbirthCustom(
|
|
ulong seq,
|
|
params (string Name, ulong Alias, TahuDataType Type, object Value)[] metrics)
|
|
{
|
|
var payload = new Payload { Seq = seq, Timestamp = 1721822400000UL };
|
|
foreach (var (name, alias, type, value) in metrics)
|
|
{
|
|
payload.Metrics.Add(BirthMetric(name, alias, type, value));
|
|
}
|
|
|
|
return Decode(payload);
|
|
}
|
|
|
|
/// <summary>A DDATA: metrics carry an alias and NOTHING else — the real post-birth wire shape.</summary>
|
|
private static SparkplugPayload Ddata(ulong seq, params (ulong Alias, TahuDataType Type, object Value)[] metrics)
|
|
=> DataPayload(seq, metrics);
|
|
|
|
/// <summary>An NDATA — identical shape to <see cref="Ddata"/>; the topic is what makes it node-scoped.</summary>
|
|
private static SparkplugPayload Ndata(ulong seq, params (ulong Alias, TahuDataType Type, object Value)[] metrics)
|
|
=> DataPayload(seq, metrics);
|
|
|
|
private static SparkplugPayload DataPayload(
|
|
ulong seq,
|
|
(ulong Alias, TahuDataType Type, object Value)[] metrics)
|
|
{
|
|
var payload = new Payload { Seq = seq, Timestamp = 1721822405000UL };
|
|
foreach (var (alias, type, value) in metrics)
|
|
{
|
|
// Deliberately NO Name and NO Datatype: that is what a Sparkplug DATA metric looks like
|
|
// after a birth, and it is the case an "absent means empty string" consumer gets wrong.
|
|
var metric = new Payload.Types.Metric { Alias = alias };
|
|
SetWireValue(metric, type, value);
|
|
payload.Metrics.Add(metric);
|
|
}
|
|
|
|
return Decode(payload);
|
|
}
|
|
|
|
/// <summary>A DDEATH — published by the edge node, and therefore sequenced.</summary>
|
|
private static SparkplugPayload Ddeath(ulong seq) =>
|
|
Decode(new Payload { Seq = seq, Timestamp = 1721822405000UL });
|
|
|
|
/// <summary>An NDEATH — the broker-published Last Will: a <c>bdSeq</c> and NO <c>seq</c> at all.</summary>
|
|
private static SparkplugPayload Ndeath(ulong bdSeq)
|
|
{
|
|
var payload = new Payload { Timestamp = 1721822405000UL };
|
|
payload.Metrics.Add(new Payload.Types.Metric
|
|
{
|
|
Name = "bdSeq",
|
|
Datatype = (uint)TahuDataType.Uint64,
|
|
LongValue = bdSeq,
|
|
});
|
|
return Decode(payload);
|
|
}
|
|
|
|
private static Payload.Types.Metric BirthMetric(string name, ulong alias, TahuDataType type, object value)
|
|
{
|
|
var metric = new Payload.Types.Metric { Name = name, Alias = alias, Datatype = (uint)type };
|
|
SetWireValue(metric, type, value);
|
|
return metric;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Writes a value into the <c>oneof</c> field Sparkplug actually carries it in — including the
|
|
/// two's-complement-in-an-unsigned-field encoding for the signed integer types, which is the
|
|
/// whole reason <see cref="SparkplugMetricBinding.Reinterpret"/> exists.
|
|
/// </summary>
|
|
private static void SetWireValue(Payload.Types.Metric metric, TahuDataType type, object value)
|
|
{
|
|
switch (type)
|
|
{
|
|
case TahuDataType.Boolean:
|
|
metric.BooleanValue = (bool)value;
|
|
break;
|
|
case TahuDataType.Float:
|
|
metric.FloatValue = (float)value;
|
|
break;
|
|
case TahuDataType.Double:
|
|
metric.DoubleValue = (double)value;
|
|
break;
|
|
case TahuDataType.Int8:
|
|
case TahuDataType.Int16:
|
|
case TahuDataType.Int32:
|
|
metric.IntValue = unchecked((uint)Convert.ToInt32(value));
|
|
break;
|
|
case TahuDataType.Uint8:
|
|
case TahuDataType.Uint16:
|
|
case TahuDataType.Uint32:
|
|
metric.IntValue = Convert.ToUInt32(value);
|
|
break;
|
|
case TahuDataType.Int64:
|
|
metric.LongValue = unchecked((ulong)Convert.ToInt64(value));
|
|
break;
|
|
case TahuDataType.Uint64:
|
|
case TahuDataType.DateTime:
|
|
metric.LongValue = Convert.ToUInt64(value);
|
|
break;
|
|
case TahuDataType.String:
|
|
case TahuDataType.Text:
|
|
case TahuDataType.Uuid:
|
|
metric.StringValue = (string)value;
|
|
break;
|
|
default:
|
|
throw new NotSupportedException($"Test helper does not encode {type}.");
|
|
}
|
|
}
|
|
|
|
/// <summary>Records every NCMD so the rebirth policy is assertable without a broker.</summary>
|
|
private sealed class RecordingPublishTransport : IMqttPublishTransport
|
|
{
|
|
public List<string> Topics { get; } = [];
|
|
|
|
public List<byte[]> Payloads { get; } = [];
|
|
|
|
public Task PublishAsync(string topic, byte[] payload, int qos, bool retain, CancellationToken cancellationToken)
|
|
{
|
|
lock (Topics)
|
|
{
|
|
Topics.Add(topic);
|
|
Payloads.Add(payload);
|
|
}
|
|
|
|
// A retained NCMD replays to the edge node on every future subscribe, driving a permanent
|
|
// rebirth loop from one stale command. Assert it here so every rebirth path is covered.
|
|
retain.ShouldBeFalse();
|
|
return Task.CompletedTask;
|
|
}
|
|
}
|
|
|
|
/// <summary>Records every SUBSCRIBE so the group/STATE filters are assertable without a broker.</summary>
|
|
private sealed class FakeSubscribeTransport : IMqttSubscribeTransport
|
|
{
|
|
public List<MqttTopicSubscription> Filters { get; } = [];
|
|
|
|
/// <summary>Topics for which the fake SUBACK reports a rejection.</summary>
|
|
public Func<string, bool> Reject { get; set; } = _ => false;
|
|
|
|
public Task<IReadOnlyList<MqttSubscribeOutcome>> SubscribeAsync(
|
|
IReadOnlyList<MqttTopicSubscription> filters,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
Filters.AddRange(filters);
|
|
IReadOnlyList<MqttSubscribeOutcome> outcomes =
|
|
[
|
|
.. filters.Select(f => new MqttSubscribeOutcome(
|
|
f.Topic,
|
|
!Reject(f.Topic),
|
|
Reject(f.Topic) ? "NotAuthorized" : "GrantedQoS1")),
|
|
];
|
|
return Task.FromResult(outcomes);
|
|
}
|
|
}
|
|
}
|