merge: MQTT browser+contracts review findings resolved (Driver.Mqtt.Browser-002..-005, Driver.Mqtt.Contracts-004..-007)
This commit is contained in:
@@ -296,6 +296,123 @@ public sealed class MqttBrowseSessionTests
|
||||
(await s.RootAsync(TestContext.Current.CancellationToken)).ShouldHaveSingleItem();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The two ingest paths must agree on what may become a tree label. MQTT forbids only
|
||||
/// <c>U+0000</c> in a topic NAME, so a publisher can legally put a newline or an ANSI escape in
|
||||
/// one — and a plain segment goes straight into the picker's displayed label, exactly where the
|
||||
/// Sparkplug path already rejects one.
|
||||
/// </summary>
|
||||
/// <summary>
|
||||
/// The node cap must hold under concurrent observation. MQTTnet delivers on a single dispatcher
|
||||
/// per client today, so this is a guard on the shape rather than a live defect — but the cap is
|
||||
/// the only thing standing between a <c>#</c> firehose and unbounded memory, and a read-then-
|
||||
/// increment pair lets N threads each see <c>cap - 1</c> and all insert.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task NodeCap_HoldsUnderConcurrentObservation()
|
||||
{
|
||||
// One round would pass by luck; the overshoot is a race. Each round drives many threads at a
|
||||
// cap of 1 through a barrier, so every thread reaches the cap check at once.
|
||||
const int rounds = 40;
|
||||
var workers = Math.Max(Environment.ProcessorCount * 2, 8);
|
||||
|
||||
for (var round = 0; round < rounds; round++)
|
||||
{
|
||||
await using var s = new MqttBrowseSession(MqttMode.Plain, nodeCap: 1);
|
||||
using var barrier = new Barrier(workers);
|
||||
|
||||
// Dedicated threads, not Task.Run: every worker BLOCKS on the barrier, and blocking that
|
||||
// many pool threads makes the pool inject new ones on its ~500 ms starvation timer — which
|
||||
// both slows the test enormously and staggers the very arrivals it is trying to align.
|
||||
var threads = Enumerable.Range(0, workers)
|
||||
.Select(i => new Thread(() =>
|
||||
{
|
||||
barrier.SignalAndWait();
|
||||
s.ObserveTopicForTest($"t{i}", "1");
|
||||
}) { IsBackground = true })
|
||||
.ToList();
|
||||
|
||||
foreach (var t in threads) t.Start();
|
||||
foreach (var t in threads) t.Join();
|
||||
|
||||
var recorded = (await s.RootAsync(TestContext.Current.CancellationToken))
|
||||
.Count(n => n.NodeId != MqttBrowseSession.TruncatedNodeId
|
||||
&& n.NodeId != MqttBrowseSession.OversizedNodeId);
|
||||
recorded.ShouldBeLessThanOrEqualTo(1, $"round {round} overshot the node cap");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PlainTopicSegmentWithAControlCharacter_IsRejectedWhole_AndSurfacesAMarker()
|
||||
{
|
||||
// Every segment here is well under MaxSegmentChars, so ONLY the control-character rule can
|
||||
// reject it — the length guard must not be what makes this test pass.
|
||||
await using var s = new MqttBrowseSession(MqttMode.Plain);
|
||||
s.ObserveTopicForTest("good/topic", "1");
|
||||
s.ObserveTopicForTest("bad/oven\nname/temp", "1");
|
||||
|
||||
s.OversizedRejected.ShouldBeTrue();
|
||||
var root = await s.RootAsync(TestContext.Current.CancellationToken);
|
||||
root.ShouldContain(n => n.NodeId == MqttBrowseSession.OversizedNodeId);
|
||||
root.ShouldContain(n => n.DisplayName == "good");
|
||||
root.ShouldNotContain(n => n.DisplayName == "bad"); // no half-built path left behind
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("\n")]
|
||||
[InlineData("\r")]
|
||||
[InlineData("\t")]
|
||||
[InlineData("\u001b")] // ANSI escape
|
||||
[InlineData("\u0000")]
|
||||
public async Task PlainTopic_EachControlCharacterForm_IsRejected(string control)
|
||||
{
|
||||
await using var s = new MqttBrowseSession(MqttMode.Plain);
|
||||
s.ObserveTopicForTest($"a/b{control}c/d", "1");
|
||||
|
||||
s.OversizedRejected.ShouldBeTrue();
|
||||
(await s.RootAsync(TestContext.Current.CancellationToken))
|
||||
.ShouldNotContain(n => n.DisplayName == "a");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Falsification control for the rule above: the guard rejects CONTROL characters, not
|
||||
/// "anything unusual". A topic segment is arbitrary UTF-8 and legitimately carries accents,
|
||||
/// spaces and punctuation — rejecting those would drop real plant topics from the picker.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task PlainTopic_PrintableNonAsciiSegments_AreStillAccepted()
|
||||
{
|
||||
await using var s = new MqttBrowseSession(MqttMode.Plain);
|
||||
s.ObserveTopicForTest("usiné/four n°3/temp %", "1");
|
||||
|
||||
s.OversizedRejected.ShouldBeFalse();
|
||||
(await s.RootAsync(TestContext.Current.CancellationToken))
|
||||
.ShouldHaveSingleItem().DisplayName.ShouldBe("usiné");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An EMPTY topic level (<c>a//b</c>, or a trailing <c>/</c>) is legal MQTT and stays accepted —
|
||||
/// deliberately NOT converged with <c>IsUsableLabel</c>'s non-empty rule. Its terminal leaf is a
|
||||
/// real, committable address (the commit reads <c>AddressFields[topic]</c>, which carries the
|
||||
/// node's full path), so rejecting the topic whole would cost the operator a legal address to
|
||||
/// tidy a blank picker row. The control-character rule is the one that had to converge: a label
|
||||
/// carrying a newline breaks the row it renders in.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task PlainTopic_WithAnEmptyLevel_IsStillRecorded()
|
||||
{
|
||||
await using var s = new MqttBrowseSession(MqttMode.Plain);
|
||||
s.ObserveTopicForTest("a//b", "1");
|
||||
|
||||
s.OversizedRejected.ShouldBeFalse();
|
||||
var leaf = (await s.ExpandAsync("a/", TestContext.Current.CancellationToken)).ShouldHaveSingleItem();
|
||||
leaf.NodeId.ShouldBe("a//b");
|
||||
|
||||
var attr = (await s.AttributesAsync("a//b", TestContext.Current.CancellationToken))
|
||||
.ShouldHaveSingleItem();
|
||||
attr.AddressFields.ShouldNotBeNull()[MqttTagConfigKeys.Topic].ShouldBe("a//b");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- payload bounds
|
||||
|
||||
[Fact]
|
||||
@@ -554,6 +671,43 @@ public sealed class MqttBrowseSessionTests
|
||||
.ShouldHaveSingleItem().Name.ShouldBe("Temp");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Siblings that share a label must still have a TOTAL order. The level is projected from a
|
||||
/// <c>ConcurrentDictionary</c> keyed by path — precisely because two Sparkplug siblings CAN share
|
||||
/// a segment — and its enumeration order is not guaranteed, so ordering by segment alone leaves
|
||||
/// the tie broken by whatever the dictionary happens to yield. The two rows are visually
|
||||
/// identical, so a reorder between successive expands is a picker that changes what a click means
|
||||
/// with nothing on screen to show it.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task SparkplugTree_SiblingsSharingALabel_AreOrderedDeterministically()
|
||||
{
|
||||
// MANY colliding pairs, not one. .NET string hashing is randomised per process, so the tie order
|
||||
// a segment-only sort falls through to is whatever THIS run's dictionary yields — with a single
|
||||
// pair it lands the right way round about half the time and the test would pass vacuously. With
|
||||
// 24 pairs, every tie has to agree by accident for that to happen.
|
||||
var names = Enumerable.Range(0, 24).Select(i => $"N{i:D2}").ToList();
|
||||
|
||||
await using var s = new MqttBrowseSession(MqttMode.SparkplugB);
|
||||
foreach (var name in names)
|
||||
{
|
||||
s.ObserveBirthForTest("Plant1", "EdgeA", null, name, SparkplugDataType.Float); // metric
|
||||
s.ObserveBirthForTest("Plant1", "EdgeA", name, "Value", SparkplugDataType.Float); // device
|
||||
}
|
||||
|
||||
// Segment first (the names, in ordinal order), then path — and Ordinal puts '/' (0x2F) before
|
||||
// ':' (0x3A), so each device folder precedes the same-named node-level metric.
|
||||
var expected = names.SelectMany(n => new[] { $"Plant1/EdgeA/{n}", $"Plant1/EdgeA::{n}" }).ToList();
|
||||
|
||||
var children = await s.ExpandAsync("Plant1/EdgeA", TestContext.Current.CancellationToken);
|
||||
children.Select(n => n.NodeId).ShouldBe(expected);
|
||||
|
||||
// …and it is stable: expanding the same level again yields the same order.
|
||||
(await s.ExpandAsync("Plant1/EdgeA", TestContext.Current.CancellationToken))
|
||||
.Select(n => n.NodeId)
|
||||
.ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SparkplugTree_EmptyBirth_StillRendersItsDeviceAsAFolder()
|
||||
{
|
||||
|
||||
@@ -423,6 +423,38 @@ public sealed class MqttSubscriptionManagerTests
|
||||
press.SeedRetained.ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An out-of-range driver-level <c>defaultQos</c> must never reach the wire. MQTT defines exactly
|
||||
/// three QoS levels, and this manager's constructor is the single enforcement point for the
|
||||
/// driver options it captures — the same place <c>MaxPayloadBytes</c> is bounded. The
|
||||
/// <c>[Range(0, 2)]</c> attribute on <c>MqttPlainOptions.DefaultQos</c> never ran: nothing in the
|
||||
/// four MQTT projects calls <c>Validator.TryValidateObject</c>, and the options arrive by
|
||||
/// <c>JsonSerializer.Deserialize</c>, so a hand-edited or API-supplied driver config put an
|
||||
/// illegal QoS straight into the SUBSCRIBE. Clamping (rather than falling back to 1) matches what
|
||||
/// the AdminUI driver form already persists, so the two cannot disagree.
|
||||
/// </summary>
|
||||
/// <param name="authored">The authored driver-level default QoS.</param>
|
||||
/// <param name="expected">The QoS the SUBSCRIBE must carry.</param>
|
||||
[Theory]
|
||||
[InlineData(5, 2)]
|
||||
[InlineData(-1, 0)]
|
||||
[InlineData(0, 0)]
|
||||
[InlineData(2, 2)]
|
||||
public async Task DefaultQos_OutsideTheLegalRange_NeverReachesTheSubscribe(int authored, int expected)
|
||||
{
|
||||
var transport = new FakeTransport();
|
||||
var mgr = new MqttSubscriptionManager(
|
||||
new MqttDriverOptions { Mode = MqttMode.Plain, Plain = new MqttPlainOptions { DefaultQos = authored } },
|
||||
"mqtt-1",
|
||||
transport);
|
||||
|
||||
// The tag authors no qos of its own, so the driver-level default is what rides out.
|
||||
mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Raw"}""")]);
|
||||
await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
|
||||
|
||||
transport.Calls.ShouldHaveSingleItem().ShouldHaveSingleItem().Qos.ShouldBe(expected);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------
|
||||
// Subscribe / SUBACK contract
|
||||
// ---------------------------------------------------------------------------------
|
||||
|
||||
@@ -177,6 +177,63 @@ public sealed class MqttTagDefinitionFactoryTests
|
||||
=> MqttTagDefinitionFactory.FromTagConfig(
|
||||
$$"""{"topic":"a/b","payloadFormat":"Raw","qos":{{qosToken}}}""", RawPath, out _).ShouldBeFalse();
|
||||
|
||||
/// <summary>
|
||||
/// <c>retainSeed</c> is a BEHAVIOURAL key and is held to the same strictness as
|
||||
/// <c>qos</c> / <c>payloadFormat</c> / <c>dataType</c>. It was the last one read leniently, so
|
||||
/// <c>"retainSeed":"false"</c> — which a CSV import or a hand-edited blob produces naturally —
|
||||
/// silently became <see langword="true"/>, the OPPOSITE of what was authored: the tag then seeds
|
||||
/// its initial value from the broker's retained message when the author asked it not to.
|
||||
/// </summary>
|
||||
/// <param name="token">The present-but-invalid <c>retainSeed</c> value.</param>
|
||||
[Theory]
|
||||
[InlineData("\"false\"")] // stringly-typed bool — what a CSV import produces
|
||||
[InlineData("\"true\"")]
|
||||
[InlineData("0")] // wrong JSON type
|
||||
[InlineData("1")]
|
||||
[InlineData("\"yes\"")]
|
||||
[InlineData("null")] // an explicit null is not "absent"
|
||||
public void FromTagConfig_MalformedRetainSeed_RejectsStrict(string token)
|
||||
=> MqttTagDefinitionFactory.FromTagConfig(
|
||||
$$"""{"topic":"a/b","payloadFormat":"Raw","retainSeed":{{token}}}""", RawPath, out _)
|
||||
.ShouldBeFalse();
|
||||
|
||||
/// <summary>The Sparkplug reader reads the same key and must reach the same verdict.</summary>
|
||||
/// <param name="token">The present-but-invalid <c>retainSeed</c> value.</param>
|
||||
[Theory]
|
||||
[InlineData("\"false\"")]
|
||||
[InlineData("0")]
|
||||
[InlineData("null")]
|
||||
public void FromSparkplugTagConfig_MalformedRetainSeed_RejectsStrict(string token)
|
||||
=> MqttTagDefinitionFactory.FromSparkplugTagConfig(
|
||||
$$"""{"groupId":"G","edgeNodeId":"E","metricName":"M","retainSeed":{{token}}}""", RawPath, out _)
|
||||
.ShouldBeFalse();
|
||||
|
||||
/// <summary>
|
||||
/// Falsification control: the strict read must still take both real booleans and an absent key.
|
||||
/// A reader that simply rejected every blob carrying the key would pass the theories above.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("true", true)]
|
||||
[InlineData("false", false)]
|
||||
public void FromTagConfig_WellFormedRetainSeed_IsStillHonoured(string token, bool expected)
|
||||
{
|
||||
MqttTagDefinitionFactory.FromTagConfig(
|
||||
$$"""{"topic":"a/b","payloadFormat":"Raw","retainSeed":{{token}}}""", RawPath, out var def)
|
||||
.ShouldBeTrue();
|
||||
def.RetainSeed.ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("\"false\"")]
|
||||
[InlineData("0")]
|
||||
public void Inspect_MalformedRetainSeed_Warns(string token)
|
||||
{
|
||||
// Otherwise clean, so retainSeed is the ONLY thing that can produce a warning.
|
||||
var warnings = MqttTagDefinitionFactory.Inspect(
|
||||
$$"""{"topic":"a/b","payloadFormat":"Raw","retainSeed":{{token}}}""");
|
||||
warnings.ShouldHaveSingleItem().ShouldContain("retainSeed", Case.Sensitive);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Inspect_CleanBlob_ReturnsEmpty()
|
||||
=> MqttTagDefinitionFactory.Inspect(
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using Google.Protobuf;
|
||||
using Org.Eclipse.Tahu.Protobuf;
|
||||
@@ -218,6 +219,66 @@ public sealed class MqttWriteEncoderTests
|
||||
=> MqttWriteEncoder.TryEncode(MqttMode.SparkplugB, Sparkplug("Filler1"), "not-a-number", out _)
|
||||
.ShouldBe(MqttWriteEncodeError.ValueNotRepresentable);
|
||||
|
||||
/// <summary>
|
||||
/// A Sparkplug <c>DateTime</c> is an UNSIGNED milliseconds-since-epoch, so a pre-1970 instant has
|
||||
/// no representation at all. The <c>(ulong)</c> cast wrapped the negative <c>long</c> around, so
|
||||
/// writing <c>1969-12-31</c> published a timestamp roughly 584 million years in the future — a
|
||||
/// coercion, and the file's own rule is that unrepresentable values are refused, never coerced.
|
||||
/// </summary>
|
||||
/// <param name="iso">The authored instant, in UTC.</param>
|
||||
[Theory]
|
||||
[InlineData("1969-12-31T23:59:59Z")]
|
||||
[InlineData("1900-01-01T00:00:00Z")]
|
||||
[InlineData("0001-01-01T00:00:00Z")]
|
||||
public void A_pre_epoch_date_time_is_refused_rather_than_wrapped(string iso)
|
||||
{
|
||||
var value = DateTime.Parse(iso, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal);
|
||||
|
||||
MqttWriteEncoder.TryEncode(
|
||||
MqttMode.SparkplugB, Sparkplug("Filler1", DriverDataType.DateTime), value, out _)
|
||||
.ShouldBe(MqttWriteEncodeError.ValueNotRepresentable);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Falsification control for the epoch bound: a post-1970 instant still encodes, and encodes to
|
||||
/// the right number. A guard that refused every <c>DateTime</c> would satisfy the theory above.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void A_post_epoch_date_time_still_encodes_to_its_unix_milliseconds()
|
||||
{
|
||||
var value = new DateTime(2026, 8, 10, 12, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
var payload = Payload.Parser.ParseFrom(EncodeOrThrow(
|
||||
MqttMode.SparkplugB, Sparkplug("Filler1", DriverDataType.DateTime), value));
|
||||
|
||||
payload.Metrics[0].Datatype.ShouldBe((uint)TahuDataType.DateTime);
|
||||
payload.Metrics[0].LongValue.ShouldBe((ulong)new DateTimeOffset(value).ToUnixTimeMilliseconds());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A <see cref="DateTimeKind.Unspecified"/> instant must encode to the SAME wire value on every
|
||||
/// node. <c>ToUniversalTime()</c> treats an unspecified kind as LOCAL, so the identical authored
|
||||
/// value encoded differently depending on the node's timezone — which the two nodes of a
|
||||
/// redundant pair are not required to share. It is read as UTC rather than refused because
|
||||
/// <c>Convert.ToDateTime("2026-08-10T12:00:00")</c> — an ordinary authored string — produces
|
||||
/// exactly this kind.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void An_unspecified_kind_date_time_encodes_as_utc_not_as_node_local_time()
|
||||
{
|
||||
var unspecified = new DateTime(2026, 8, 10, 12, 0, 0, DateTimeKind.Unspecified);
|
||||
var sameInstantUtc = DateTime.SpecifyKind(unspecified, DateTimeKind.Utc);
|
||||
|
||||
var fromUnspecified = Payload.Parser.ParseFrom(EncodeOrThrow(
|
||||
MqttMode.SparkplugB, Sparkplug("Filler1", DriverDataType.DateTime), unspecified));
|
||||
var fromUtc = Payload.Parser.ParseFrom(EncodeOrThrow(
|
||||
MqttMode.SparkplugB, Sparkplug("Filler1", DriverDataType.DateTime), sameInstantUtc));
|
||||
|
||||
fromUnspecified.Metrics[0].LongValue.ShouldBe(fromUtc.Metrics[0].LongValue);
|
||||
fromUnspecified.Metrics[0].LongValue
|
||||
.ShouldBe((ulong)new DateTimeOffset(sameInstantUtc).ToUnixTimeMilliseconds());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_type_with_no_command_encoding_is_refused_rather_than_stringified()
|
||||
// Reference has no Sparkplug wire representation. Falling back to String would put a value on
|
||||
|
||||
@@ -150,6 +150,78 @@ public sealed class SparkplugTopicTests
|
||||
public void ToTopicString_RoundTrips(string topic)
|
||||
=> SparkplugTopic.Parse(topic).ToTopicString().ShouldBe(topic);
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="SparkplugTopic.Format"/> must enforce the same per-segment rule
|
||||
/// <see cref="SparkplugTopic.TryParse"/> does. It validated only null/empty on
|
||||
/// <c>groupId</c>/<c>edgeNodeId</c> and NOTHING at all on <c>deviceId</c>, so the type was not
|
||||
/// round-trip-closed: a <c>+</c>/<c>#</c> id produced a topic MQTT-3.3.2-2 forbids in a PUBLISH,
|
||||
/// and a <c>/</c> in a <c>deviceId</c> silently added a segment. Every production caller is
|
||||
/// already guarded, so the invariant belongs on the type that owns it rather than on the next
|
||||
/// caller to arrive.
|
||||
/// </summary>
|
||||
/// <param name="groupId">The group id to format with.</param>
|
||||
/// <param name="edgeNodeId">The edge-node id to format with.</param>
|
||||
/// <param name="deviceId">The device id to format with, or <see langword="null"/>.</param>
|
||||
[Theory]
|
||||
[InlineData("Plant+1", "EdgeA", null)]
|
||||
[InlineData("Plant#", "EdgeA", null)]
|
||||
[InlineData("Plant1", "Edge+A", null)]
|
||||
[InlineData("Plant1", "Edge#A", null)]
|
||||
[InlineData("Plant1", "EdgeA", "Fill+er")]
|
||||
[InlineData("Plant1", "EdgeA", "Fill#er")]
|
||||
[InlineData("Ot/Sim", "EdgeA", null)] // two segments, not one — a DIFFERENT node than named
|
||||
[InlineData("Plant1", "EdgeA", "Fill/er")]
|
||||
[InlineData("Plant1", "EdgeA", "")] // would emit a trailing '/', which never re-parses
|
||||
public void Format_SegmentThatIsNotALegalTopicSegment_Throws(string groupId, string edgeNodeId, string? deviceId)
|
||||
=> Should.Throw<ArgumentException>(
|
||||
() => SparkplugTopic.Format(groupId, SparkplugMessageType.DCMD, edgeNodeId, deviceId));
|
||||
|
||||
[Theory]
|
||||
[InlineData("host+1")]
|
||||
[InlineData("host#")]
|
||||
public void FormatState_HostIdThatIsNotALegalTopicSegment_Throws(string hostId)
|
||||
=> Should.Throw<ArgumentException>(() => SparkplugTopic.FormatState(hostId));
|
||||
|
||||
/// <summary>
|
||||
/// Falsification control: the rule is the parser's <c>IsValidSegment</c>, not "reject anything
|
||||
/// unusual". A guard that rejected every id would satisfy both theories above, and a Sparkplug id
|
||||
/// is otherwise an opaque identifier — spaces, dots and dashes are all legitimate.
|
||||
/// </summary>
|
||||
/// <param name="id">A legal, if unusual, segment.</param>
|
||||
[Theory]
|
||||
[InlineData("Plant 1")]
|
||||
[InlineData("plant.one")]
|
||||
[InlineData("plant-one_2")]
|
||||
[InlineData("usiné")]
|
||||
public void Format_UnusualButLegalSegments_AreStillAccepted(string id)
|
||||
=> SparkplugTopic.Parse(SparkplugTopic.Format(id, SparkplugMessageType.DCMD, id, id))
|
||||
.ShouldBe(new SparkplugTopic(SparkplugMessageType.DCMD, id, id, id, null));
|
||||
|
||||
/// <summary>
|
||||
/// The closure property the asymmetry broke: anything <see cref="SparkplugTopic.Format"/> emits
|
||||
/// must parse back to the instance it was built from. It is asserted over the FORMATTER's own
|
||||
/// output rather than over a hand-written topic list, so a future formatting change cannot leave
|
||||
/// the corpus describing a shape the type no longer emits.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Format_Output_AlwaysReParsesToTheSameInstance()
|
||||
{
|
||||
SparkplugMessageType[] deviceScoped =
|
||||
[SparkplugMessageType.DBIRTH, SparkplugMessageType.DDATA, SparkplugMessageType.DDEATH, SparkplugMessageType.DCMD];
|
||||
|
||||
foreach (var type in Enum.GetValues<SparkplugMessageType>())
|
||||
{
|
||||
if (type == SparkplugMessageType.STATE) continue;
|
||||
var deviceId = deviceScoped.Contains(type) ? "Filler 1" : null;
|
||||
var topic = SparkplugTopic.Format("Plant 1", type, "Edge.A", deviceId);
|
||||
|
||||
SparkplugTopic.Parse(topic).ShouldBe(new SparkplugTopic(type, "Plant 1", "Edge.A", deviceId, null));
|
||||
}
|
||||
|
||||
SparkplugTopic.Parse(SparkplugTopic.FormatState("otopcua host 1"))
|
||||
.ShouldBe(new SparkplugTopic(SparkplugMessageType.STATE, null, null, null, "otopcua host 1"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToTopicString_LegacyStateTopic_NormalisesToV3Form()
|
||||
// Parsed from the legacy no-prefix form, but formatting always emits the v3.0 shape.
|
||||
|
||||
Reference in New Issue
Block a user