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:
@@ -260,6 +260,37 @@ public sealed class TagConfigNonEnumParityTests
|
||||
TagConfigValidator.Validate(DriverTypeNames.Mqtt, blob).ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// MQTT's <c>retainSeed</c>, held to the same rule as <c>qos</c>: a JSON boolean or absent, and
|
||||
/// nothing else. The stringly-typed form is what a CSV import produces, and a lenient read turned
|
||||
/// it into the default <c>true</c> — the INVERSE of the authored intent. The model reads the key
|
||||
/// leniently into its own nullable field, so this parity holds only because
|
||||
/// <c>TagConfigParseGate</c> asks the driver's own reader rather than the model's typed fields.
|
||||
/// </summary>
|
||||
/// <param name="blob">A blob whose only fault is <c>retainSeed</c>.</param>
|
||||
[Theory]
|
||||
[InlineData("""{"topic":"otopcua/fixture/a","retainSeed":"false"}""")]
|
||||
[InlineData("""{"topic":"otopcua/fixture/a","retainSeed":0}""")]
|
||||
[InlineData("""{"topic":"otopcua/fixture/a","retainSeed":null}""")]
|
||||
[InlineData("""{"groupId":"OtOpcUaSim","edgeNodeId":"EdgeA","metricName":"M","retainSeed":"false"}""")]
|
||||
public void Mqtt_refuses_a_retainSeed_its_reader_refuses(string blob)
|
||||
{
|
||||
RuntimeAccepts(DriverTypeNames.Mqtt, blob).ShouldBeFalse();
|
||||
TagConfigValidator.Validate(DriverTypeNames.Mqtt, blob).ShouldNotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
/// <summary>The paired acceptances: both real booleans, and an absent key.</summary>
|
||||
/// <param name="blob">A blob whose <c>retainSeed</c> is well-formed or absent.</param>
|
||||
[Theory]
|
||||
[InlineData("""{"topic":"otopcua/fixture/a","retainSeed":true}""")]
|
||||
[InlineData("""{"topic":"otopcua/fixture/a","retainSeed":false}""")]
|
||||
[InlineData("""{"topic":"otopcua/fixture/a"}""")]
|
||||
public void Mqtt_accepts_a_retainSeed_its_reader_accepts(string blob)
|
||||
{
|
||||
RuntimeAccepts(DriverTypeNames.Mqtt, blob).ShouldBeTrue();
|
||||
TagConfigValidator.Validate(DriverTypeNames.Mqtt, blob).ShouldBeNull();
|
||||
}
|
||||
|
||||
// =============================================================================================
|
||||
// 2. The advisory pin — the caution that shapes the whole implementation.
|
||||
// =============================================================================================
|
||||
|
||||
Reference in New Issue
Block a user