fix(mqtt-contracts): strict retainSeed, real bounds instead of dead [Range]s, DateTime edges, closed topic formatting
Driver.Mqtt.Contracts-004/-005/-006/-007. -004: retainSeed was the last behavioural key read leniently, so "retainSeed":"false" — what a CSV import produces — silently became the default TRUE, the INVERSE of the authored intent. Both mappers now read it through TryReadBoolStrict (the qos absent/valid/present-but-invalid split) and DescribeRejection names it. The authoring side needed no hand-mirrored rule: MqttTagConfigModel.Validate already consults this same parse through TagConfigParseGate, so the save is blocked with the inspector's wording — pinned by a new AdminUI parity theory pair. -005: the seven [Range] attributes on MqttDriverOptions/MqttPlainOptions never ran — the options are bound by JsonSerializer, and nothing in the four MQTT projects calls Validator.TryValidateObject. Two of them CONTRADICTED the real authority (KeepAlive's [Range(1,…)] forbade the zero Normalize admits as legal MQTT; MaxPayloadBytes' forbade the non-positive value its own remarks document as falling back to 1 MiB), so wiring them up would have broken deliberate behaviour. Deleted, with each property naming where its bound actually lives. The one escape they papered over is closed: an out-of-range defaultQos rode into the SUBSCRIBE verbatim, and MqttSubscriptionManager's constructor now clamps it exactly as the AdminUI driver form already persists. -006: the DateTime write arm cast a negative long to ulong, so 1969-12-31 published a timestamp ~584 million years in the future; it is now refused as ValueNotRepresentable. An Unspecified kind was handed to ToUniversalTime(), which reads it as LOCAL — the same authored instant encoded differently on the two nodes of a redundant pair; it is now read as UTC. -007: Format/FormatState enforced only null/empty, and nothing at all on deviceId, so the type was not round-trip-closed. They now apply the parser's IsValidSegment (plus '/', which only the format side can see). MqttWriteEncoder's own guards stay — TryEncode returns an error rather than throwing, and that contract does not change.
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user