1f4f4fca8f
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
414 lines
18 KiB
C#
414 lines
18 KiB
C#
using System.Globalization;
|
|
using System.Text;
|
|
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>
|
|
/// Unit tests for <see cref="MqttWriteEncoder"/> — the pure half of MQTT write-through (Gitea #508):
|
|
/// what a write encodes to, and which writes are refused before anything reaches a broker.
|
|
/// </summary>
|
|
[Trait("Category", "Unit")]
|
|
public sealed class MqttWriteEncoderTests
|
|
{
|
|
private static MqttTagDefinition Plain(
|
|
string? writeTopic,
|
|
MqttPayloadFormat format = MqttPayloadFormat.Scalar,
|
|
DriverDataType dataType = DriverDataType.Float64)
|
|
=> new(
|
|
Name: "Plant/Mqtt/dev1/Setpoint",
|
|
Topic: "factory/oven/temp",
|
|
PayloadFormat: format,
|
|
JsonPath: "$",
|
|
DataType: dataType,
|
|
Qos: null,
|
|
RetainSeed: true,
|
|
WriteTopic: writeTopic);
|
|
|
|
private static MqttTagDefinition Sparkplug(
|
|
string? deviceId,
|
|
DriverDataType dataType = DriverDataType.Float64,
|
|
string metricName = "Speed")
|
|
=> new(
|
|
Name: "Plant/Mqtt/dev1/Speed",
|
|
Topic: "",
|
|
PayloadFormat: MqttPayloadFormat.Json,
|
|
JsonPath: "$",
|
|
DataType: dataType,
|
|
Qos: null,
|
|
RetainSeed: true,
|
|
GroupId: "OtOpcUaSim",
|
|
EdgeNodeId: "EdgeA",
|
|
DeviceId: deviceId,
|
|
MetricName: metricName);
|
|
|
|
// ---- The refusal that is the whole point of the feature -------------------------------------
|
|
|
|
[Fact]
|
|
public void A_plain_tag_with_no_write_topic_is_refused_rather_than_published_nowhere()
|
|
{
|
|
// Before #508 there was no IWritable at all — honest read-only. The one outcome that would be
|
|
// WORSE is a node that accepts a write, reports Good and publishes nothing. So this must be a
|
|
// refusal, and specifically the "no destination" one, which the driver maps to BadNotWritable.
|
|
var error = MqttWriteEncoder.TryEncode(MqttMode.Plain, Plain(writeTopic: null), 42.5, out var encoded);
|
|
|
|
error.ShouldBe(MqttWriteEncodeError.NoWriteDestination);
|
|
encoded.Topic.ShouldBeNull();
|
|
encoded.Payload.ShouldBeNull();
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("")]
|
|
[InlineData(" ")]
|
|
public void A_blank_write_topic_is_treated_as_absent(string writeTopic)
|
|
=> MqttWriteEncoder.TryEncode(MqttMode.Plain, Plain(writeTopic), 1, out _)
|
|
.ShouldBe(MqttWriteEncodeError.NoWriteDestination);
|
|
|
|
[Fact]
|
|
public void A_sparkplug_tag_with_an_incomplete_tuple_has_no_command_address()
|
|
{
|
|
// A Sparkplug tag's command address IS its binding tuple, so an incomplete one is the same
|
|
// failure as a missing write topic, not a different kind.
|
|
var def = Sparkplug(deviceId: null) with { MetricName = null };
|
|
|
|
MqttWriteEncoder.TryEncode(MqttMode.SparkplugB, def, 1.0, out _)
|
|
.ShouldBe(MqttWriteEncodeError.NoWriteDestination);
|
|
}
|
|
|
|
// ---- Plain encoding --------------------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public void Plain_publishes_to_the_write_topic_not_the_read_topic()
|
|
{
|
|
// The two-field split is the point: publishing a command onto the tag's own read topic would
|
|
// echo back and read as a confirmed device value.
|
|
var def = Plain("factory/oven/temp/set");
|
|
|
|
MqttWriteEncoder.TryEncode(MqttMode.Plain, def, 42.5, out var encoded)
|
|
.ShouldBe(MqttWriteEncodeError.None);
|
|
|
|
encoded.Topic.ShouldBe("factory/oven/temp/set");
|
|
encoded.Topic.ShouldNotBe(def.Topic);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(MqttPayloadFormat.Scalar)]
|
|
[InlineData(MqttPayloadFormat.Raw)]
|
|
public void Scalar_and_raw_encode_the_value_as_utf8_text(MqttPayloadFormat format)
|
|
{
|
|
MqttWriteEncoder.TryEncode(MqttMode.Plain, Plain("t/set", format), 42.5, out var encoded)
|
|
.ShouldBe(MqttWriteEncodeError.None);
|
|
|
|
Encoding.UTF8.GetString(encoded.Payload).ShouldBe("42.5");
|
|
}
|
|
|
|
[Fact]
|
|
public void Numbers_are_encoded_with_the_invariant_culture()
|
|
{
|
|
// A comma decimal separator would publish a number the device cannot parse — and it would only
|
|
// reproduce on a host with a European locale, which is the worst kind of bug to chase.
|
|
var previous = Thread.CurrentThread.CurrentCulture;
|
|
Thread.CurrentThread.CurrentCulture = new System.Globalization.CultureInfo("de-DE");
|
|
try
|
|
{
|
|
MqttWriteEncoder.TryEncode(MqttMode.Plain, Plain("t/set"), 42.5, out var encoded)
|
|
.ShouldBe(MqttWriteEncodeError.None);
|
|
|
|
Encoding.UTF8.GetString(encoded.Payload).ShouldBe("42.5");
|
|
}
|
|
finally
|
|
{
|
|
Thread.CurrentThread.CurrentCulture = previous;
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void Json_encodes_a_bare_json_literal()
|
|
{
|
|
MqttWriteEncoder.TryEncode(
|
|
MqttMode.Plain, Plain("t/set", MqttPayloadFormat.Json), 42.5, out var encoded)
|
|
.ShouldBe(MqttWriteEncodeError.None);
|
|
|
|
Encoding.UTF8.GetString(encoded.Payload).ShouldBe("42.5");
|
|
}
|
|
|
|
[Fact]
|
|
public void Json_quotes_a_string_value()
|
|
=> Encoding.UTF8.GetString(
|
|
EncodeOrThrow(MqttMode.Plain, Plain("t/set", MqttPayloadFormat.Json, DriverDataType.String), "on"))
|
|
.ShouldBe("\"on\"");
|
|
|
|
[Fact]
|
|
public void A_null_value_is_refused()
|
|
// A zero-length MQTT payload is a legitimate clear-the-retained-message instruction — an
|
|
// entirely different operation. Encoding null as "no bytes" would conflate the two.
|
|
=> MqttWriteEncoder.TryEncode(MqttMode.Plain, Plain("t/set"), null, out _)
|
|
.ShouldBe(MqttWriteEncodeError.ValueNotRepresentable);
|
|
|
|
// ---- Sparkplug command encoding ---------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public void A_device_metric_encodes_as_a_DCMD_addressed_to_that_device()
|
|
{
|
|
// Addressing a device metric with NCMD would command the NODE, which does not own it.
|
|
MqttWriteEncoder.TryEncode(MqttMode.SparkplugB, Sparkplug("Filler1"), 12.5, out var encoded)
|
|
.ShouldBe(MqttWriteEncodeError.None);
|
|
|
|
encoded.Topic.ShouldBe("spBv1.0/OtOpcUaSim/DCMD/EdgeA/Filler1");
|
|
}
|
|
|
|
[Fact]
|
|
public void An_edge_node_metric_encodes_as_an_NCMD()
|
|
=> EncodeTopic(MqttMode.SparkplugB, Sparkplug(deviceId: null))
|
|
.ShouldBe("spBv1.0/OtOpcUaSim/NCMD/EdgeA");
|
|
|
|
[Fact]
|
|
public void The_command_carries_the_metric_by_name_with_an_explicit_datatype()
|
|
{
|
|
// By NAME, never by alias: an alias may be reused for a different metric across a rebirth.
|
|
// Datatype explicitly, because a node that treats it as ambiguous drops the command SILENTLY —
|
|
// the exact failure mode this feature must not have.
|
|
var bytes = EncodeOrThrow(MqttMode.SparkplugB, Sparkplug("Filler1"), 12.5);
|
|
var payload = Payload.Parser.ParseFrom(bytes);
|
|
|
|
payload.Metrics.Count.ShouldBe(1);
|
|
var metric = payload.Metrics[0];
|
|
metric.Name.ShouldBe("Speed");
|
|
metric.Alias.ShouldBe(0ul);
|
|
metric.Datatype.ShouldBe((uint)TahuDataType.Double);
|
|
metric.DoubleValue.ShouldBe(12.5);
|
|
metric.Timestamp.ShouldBeGreaterThan(0ul);
|
|
payload.Timestamp.ShouldBeGreaterThan(0ul);
|
|
}
|
|
|
|
[Fact]
|
|
public void The_command_carries_no_sequence_number()
|
|
{
|
|
// seq belongs to the messages an edge node itself publishes. A strict node validating a
|
|
// host-supplied one against its own counter would have grounds to reject the command.
|
|
var payload = Payload.Parser.ParseFrom(EncodeOrThrow(MqttMode.SparkplugB, Sparkplug("Filler1"), 1.0));
|
|
|
|
payload.HasSeq.ShouldBeFalse();
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(DriverDataType.Boolean, true, (uint)TahuDataType.Boolean)]
|
|
[InlineData(DriverDataType.Int32, 7, (uint)TahuDataType.Int32)]
|
|
[InlineData(DriverDataType.Int64, 7L, (uint)TahuDataType.Int64)]
|
|
[InlineData(DriverDataType.UInt32, 7, (uint)TahuDataType.Uint32)]
|
|
[InlineData(DriverDataType.Float32, 1.5f, (uint)TahuDataType.Float)]
|
|
[InlineData(DriverDataType.String, "x", (uint)TahuDataType.String)]
|
|
public void The_declared_data_type_selects_the_wire_datatype(
|
|
DriverDataType dataType, object value, uint expectedWireType)
|
|
{
|
|
var payload = Payload.Parser.ParseFrom(
|
|
EncodeOrThrow(MqttMode.SparkplugB, Sparkplug("Filler1", dataType), value));
|
|
|
|
payload.Metrics[0].Datatype.ShouldBe(expectedWireType);
|
|
}
|
|
|
|
[Fact]
|
|
public void An_unrepresentable_value_is_refused_rather_than_coerced()
|
|
// "not-a-number" into a Float64 tag must not silently become 0 on a live plant.
|
|
=> 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
|
|
// the wire that is not what the tag's type says it is.
|
|
=> MqttWriteEncoder.TryEncode(
|
|
MqttMode.SparkplugB, Sparkplug("Filler1", DriverDataType.Reference), "ref", out _)
|
|
.ShouldBe(MqttWriteEncodeError.UnsupportedDataType);
|
|
|
|
[Fact]
|
|
public void The_mode_decides_the_shape_not_the_blob()
|
|
{
|
|
// A tag can legitimately carry both shapes' keys (the editor preserves unknown keys across a
|
|
// retype), so sniffing would make one authored tag mean two different things.
|
|
var both = Sparkplug("Filler1") with { WriteTopic = "plain/set" };
|
|
|
|
EncodeTopic(MqttMode.Plain, both).ShouldBe("plain/set");
|
|
EncodeTopic(MqttMode.SparkplugB, both).ShouldBe("spBv1.0/OtOpcUaSim/DCMD/EdgeA/Filler1");
|
|
}
|
|
|
|
// ---- A destination that cannot be published to is not a destination -------------------------
|
|
|
|
[Theory]
|
|
[InlineData("factory/+/set")]
|
|
[InlineData("factory/oven/#")]
|
|
public void A_wildcard_write_topic_is_refused_rather_than_published(string writeTopic)
|
|
{
|
|
// MQTT-3.3.2-2: a PUBLISH topic MUST NOT contain a wildcard, and a conformant broker answers a
|
|
// protocol violation by CLOSING the connection — which, for this driver, is the same connection
|
|
// every subscription lives on. So one write to a mis-authored tag takes the WHOLE driver dark,
|
|
// not just that tag. The typed AdminUI editor already refuses this, but a raw-JSON / CSV /
|
|
// API-authored blob reaches the encoder untouched, so the refusal has to live here too.
|
|
MqttWriteEncoder.TryEncode(MqttMode.Plain, Plain(writeTopic), 1, out var encoded)
|
|
.ShouldBe(MqttWriteEncodeError.NoWriteDestination);
|
|
encoded.Topic.ShouldBeNull();
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("Ot/Sim", "EdgeA", null)]
|
|
[InlineData("OtSim", "Edge+A", null)]
|
|
[InlineData("OtSim", "EdgeA", "Fill#er")]
|
|
[InlineData("OtSim", "EdgeA", "Fill/er")]
|
|
public void A_binding_tuple_that_is_not_a_legal_topic_segment_has_no_command_address(
|
|
string groupId, string edgeNodeId, string? deviceId)
|
|
{
|
|
// The same protocol hazard on the Sparkplug side, plus one the Plain side does not have: the
|
|
// tuple IS the command topic, so a '/' silently re-shapes it into a DIFFERENT address (an
|
|
// NCMD for group "Ot" node "Sim" rather than the tag's own node) while '+'/'#' make it
|
|
// unpublishable. Refusing is the only honest verdict for all three.
|
|
var def = Sparkplug(deviceId) with { GroupId = groupId, EdgeNodeId = edgeNodeId };
|
|
|
|
MqttWriteEncoder.TryEncode(MqttMode.SparkplugB, def, 1.0, out _)
|
|
.ShouldBe(MqttWriteEncodeError.NoWriteDestination);
|
|
}
|
|
|
|
[Fact]
|
|
public void A_metric_name_containing_a_slash_still_encodes()
|
|
{
|
|
// The falsification control for the rule above. Sparkplug's own canonical metric names use '/'
|
|
// (`Node Control/Rebirth`), and metricName rides in the PAYLOAD, never in the topic — so the
|
|
// segment rule must not reach it or every conformant node-control write would be refused.
|
|
MqttWriteEncoder.TryEncode(
|
|
MqttMode.SparkplugB,
|
|
Sparkplug(deviceId: null, DriverDataType.Boolean, metricName: "Node Control/Rebirth"),
|
|
true,
|
|
out var encoded)
|
|
.ShouldBe(MqttWriteEncodeError.None);
|
|
|
|
encoded.Topic.ShouldBe("spBv1.0/OtOpcUaSim/NCMD/EdgeA");
|
|
Payload.Parser.ParseFrom(encoded.Payload).Metrics[0].Name.ShouldBe("Node Control/Rebirth");
|
|
}
|
|
|
|
// ---- STAB-25 / STAB-26 (#602) ---------------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public void A_plain_write_publishes_at_QoS1_so_Good_means_the_broker_accepted_it()
|
|
{
|
|
MqttWriteEncoder.TryEncode(MqttMode.Plain, Plain("oven/set"), 42.5, out var encoded)
|
|
.ShouldBe(MqttWriteEncodeError.None);
|
|
|
|
encoded.Qos.ShouldBe(1);
|
|
}
|
|
|
|
[Fact]
|
|
public void A_sparkplug_command_stays_at_QoS0()
|
|
{
|
|
// Pinned beside the Plain case so the two cannot be "unified" by accident: an NCMD/DCMD retry from
|
|
// QoS 1 redelivery would re-apply a command the device already took.
|
|
MqttWriteEncoder.TryEncode(MqttMode.SparkplugB, Sparkplug("Filler1"), 1.0, out var encoded)
|
|
.ShouldBe(MqttWriteEncodeError.None);
|
|
|
|
encoded.Qos.ShouldBe(0);
|
|
}
|
|
|
|
[Fact]
|
|
public void An_unauthored_sparkplug_type_is_commanded_with_the_birth_declared_type()
|
|
{
|
|
// DataTypeAuthored=false: the record's Float64 is only a default. The birth said Int32, so the DCMD
|
|
// must carry Int32 — a Double-typed command to an Int32 metric is the silent-drop shape.
|
|
var def = Sparkplug("Filler1") with { DataTypeAuthored = false };
|
|
|
|
MqttWriteEncoder.TryEncode(MqttMode.SparkplugB, def, 7, out var encoded, DriverDataType.Int32)
|
|
.ShouldBe(MqttWriteEncodeError.None);
|
|
|
|
var metric = Payload.Parser.ParseFrom(encoded.Payload).Metrics[0];
|
|
metric.Datatype.ShouldBe((uint)TahuDataType.Int32);
|
|
metric.IntValue.ShouldBe(7u);
|
|
}
|
|
|
|
[Fact]
|
|
public void An_authored_sparkplug_type_wins_over_the_birth_type()
|
|
{
|
|
// Same precedence as the ingest path: authored beats birth.
|
|
var def = Sparkplug("Filler1", DriverDataType.Float64) with { DataTypeAuthored = true };
|
|
|
|
MqttWriteEncoder.TryEncode(MqttMode.SparkplugB, def, 7, out var encoded, DriverDataType.Int32)
|
|
.ShouldBe(MqttWriteEncodeError.None);
|
|
|
|
Payload.Parser.ParseFrom(encoded.Payload).Metrics[0].Datatype.ShouldBe((uint)TahuDataType.Double);
|
|
}
|
|
|
|
private static string EncodeTopic(MqttMode mode, MqttTagDefinition def)
|
|
{
|
|
MqttWriteEncoder.TryEncode(mode, def, 1.0, out var encoded).ShouldBe(MqttWriteEncodeError.None);
|
|
return encoded.Topic;
|
|
}
|
|
|
|
private static byte[] EncodeOrThrow(MqttMode mode, MqttTagDefinition def, object value)
|
|
{
|
|
MqttWriteEncoder.TryEncode(mode, def, value, out var encoded).ShouldBe(MqttWriteEncodeError.None);
|
|
return encoded.Payload;
|
|
}
|
|
}
|