Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttDriverWriteTests.cs
T
Joseph Doherty 72be19225d feat(mqtt): write-through (IWritable) — Plain publish + Sparkplug NCMD/DCMD (#508)
MqttDriver implemented no IWritable, so every MQTT node materialized read-only
regardless of what the operator authored. It now publishes.

  Plain      -> the tag's writeTopic; UTF-8 text (Raw/Scalar) or a bare JSON
                literal (Json)
  Sparkplug  -> DCMD for a device metric, NCMD for an edge-node metric, carrying
                the metric BY NAME with an explicit Datatype and no seq

Both at QoS 0, retain:false. Retain is deliberately not configurable — a retained
command is replayed by the broker on every future subscribe, including the
device's own reconnect, so one stale write would be re-applied forever.

writeTopic is a new, separate TagConfig key, and its ABSENCE is meaningful: the
write is refused BadNotWritable and the node is discovered ViewOnly. The issue's
"do not skip" warning drove the design — the one outcome worse than read-only
nodes is a node that looks writable, returns Good and publishes nothing. Reusing
the read topic would be worse still: it would echo back and read as a confirmed
device value. Browse-time writability is computed by the SAME encoder the write
path uses, so a node cannot advertise writability it would then refuse.

Good means published, not applied — MQTT has no application-level write ack, so
this is Galaxy's optimistic-Good posture and inherits its property: a failed
DEVICE write can never be surfaced. A failed PUBLISH is reported Bad; the
optimism covers what the device did with the command, never whether it was sent.
WriteIdempotent stays default-off.

The encoder is pure and lives in .Contracts, so both wire shapes are testable
without a broker. Unrepresentable values and types with no Sparkplug encoding are
refused rather than coerced — a Reference must not silently become a String on
the wire, and "not-a-number" must not become 0 on a live plant.

LIVE GATE PASSED against the real Mosquitto fixture on 10.100.0.35: 21/21
integration tests, including 6 new ones that observe the published message with
an INDEPENDENT broker subscriber — a fake asserting "publish was called" could
not tell the feature working from the feature publishing to the wrong topic.

The gate earned its keep: the NCMD test initially failed because NCMD is a SHARED
channel — the driver publishes its own rebirth request (Node Control/Rebirth) on
the very topic an edge-node metric write uses, and it arrived first. Correct
driver behaviour, wrong test; the watcher now matches on metric name. Documented,
because anything consuming that topic needs to know. DCMD has no contention.

Offline: 24 encoder + 9 driver tests. The four new status constants are NAMED so
StatusCodeParityTests can see them (#497/#519) — it went 258 -> 262 and validated
each against the pinned SDK.

Also corrects the Mqtt.md "Known gaps" table: prunes the six rows closed in this
round, and fixes the #507 row, which still described rediscovery as inert after
that issue was closed as superseded and its injection path deleted.

Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
2026-07-31 06:33:23 -04:00

161 lines
6.7 KiB
C#

using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests;
/// <summary>
/// Unit tests for <see cref="MqttDriver"/>'s <see cref="IWritable"/> leg (Gitea #508) — the batch
/// contract and the per-slot refusals, up to (not including) the broker publish.
/// </summary>
/// <remarks>
/// The publish itself is the live gate's job: it needs a broker, and asserting it here would only
/// prove that a fake was called. What IS worth pinning offline is everything that decides whether a
/// write should reach a broker at all — because that is where a silent no-op would hide.
/// </remarks>
[Trait("Category", "Unit")]
public sealed class MqttDriverWriteTests
{
private const string Path = "Plant/Mqtt/dev1/Setpoint";
private const string ReadOnlyPath = "Plant/Mqtt/dev1/Temp";
private static RawTagEntry PlainTag(string rawPath, string? writeTopic)
{
var writeKey = writeTopic is null ? "" : $$""","writeTopic":"{{writeTopic}}" """;
return new RawTagEntry(
rawPath,
$$"""{"topic":"factory/oven/temp","payloadFormat":"Scalar","dataType":"Float64"{{writeKey}}}""",
WriteIdempotent: false);
}
private static MqttDriver PlainDriver(params RawTagEntry[] tags)
=> new(new MqttDriverOptions { Mode = MqttMode.Plain, RawTags = tags }, "d", null);
[Fact]
public void MqttDriver_implements_IWritable()
// The interface's presence is the feature: without it every MQTT node materialises read-only,
// regardless of what the operator authored on the tag.
=> PlainDriver().ShouldBeAssignableTo<IWritable>();
[Fact]
public async Task An_unknown_reference_is_refused_in_its_own_slot()
{
var results = await PlainDriver(PlainTag(Path, "oven/set"))
.WriteAsync([new WriteRequest("Plant/Mqtt/dev1/NotATag", 1.0)], default);
results.Count.ShouldBe(1);
results[0].StatusCode.ShouldBe(0x80340000u); // BadNodeIdUnknown
}
[Fact]
public async Task A_tag_with_no_write_topic_is_refused_as_not_writable()
{
// NOT BadCommunicationError and NOT Good: the tag is addressable and the broker is irrelevant —
// the operator simply never said where a write should go. The status has to say that, or the
// only diagnosis available is "writes don't work".
var results = await PlainDriver(PlainTag(ReadOnlyPath, writeTopic: null))
.WriteAsync([new WriteRequest(ReadOnlyPath, 1.0)], default);
results[0].StatusCode.ShouldBe(0x803B0000u); // BadNotWritable
}
[Fact]
public async Task A_writable_tag_on_a_disconnected_driver_reports_a_communication_failure()
{
// Reported, never queued. A write buffered now and flushed at an unbounded later moment is the
// replay hazard WriteIdempotent exists to prevent — and this would be the driver deciding it on
// the operator's behalf.
var results = await PlainDriver(PlainTag(Path, "oven/set"))
.WriteAsync([new WriteRequest(Path, 42.5)], default);
results[0].StatusCode.ShouldBe(0x80050000u); // BadCommunicationError
}
[Fact]
public async Task One_bad_request_does_not_fail_the_others_in_the_batch()
{
// Mirrors ReadAsync: failing the whole call would let one stale reference reject every other
// write in the same OPC UA request.
var driver = PlainDriver(PlainTag(Path, "oven/set"), PlainTag(ReadOnlyPath, writeTopic: null));
var results = await driver.WriteAsync(
[
new WriteRequest("Plant/Mqtt/dev1/NotATag", 1.0),
new WriteRequest(ReadOnlyPath, 1.0),
new WriteRequest(Path, 42.5),
],
default);
// Order-preserving, one status per request, each naming its own distinct cause.
results.Count.ShouldBe(3);
results[0].StatusCode.ShouldBe(0x80340000u); // BadNodeIdUnknown
results[1].StatusCode.ShouldBe(0x803B0000u); // BadNotWritable
results[2].StatusCode.ShouldBe(0x80050000u); // BadCommunicationError — resolved and encodable
}
[Fact]
public async Task An_empty_batch_is_answered_with_an_empty_result()
=> (await PlainDriver().WriteAsync([], default)).ShouldBeEmpty();
[Fact]
public async Task WriteAsync_rejects_a_null_batch()
=> await Should.ThrowAsync<ArgumentNullException>(
async () => await PlainDriver().WriteAsync(null!, default));
// ---- Discovery advertises writability consistently with the write path ------------------------
[Fact]
public async Task A_tag_with_a_write_topic_is_discovered_as_an_operate_node()
{
var builder = new WriteCapturingBuilder();
await PlainDriver(PlainTag(Path, "oven/set")).DiscoverAsync(builder, default);
builder.Variables.ShouldHaveSingleItem();
builder.Variables[0].SecurityClass.ShouldBe(SecurityClassification.Operate);
// Default-off regardless: MQTT gives no application-level write ack, and a Sparkplug command is
// not necessarily idempotent. Opting in is the operator's call, per tag.
builder.Variables[0].WriteIdempotent.ShouldBeFalse();
}
[Fact]
public async Task A_tag_without_a_write_topic_stays_view_only()
{
// The browse-time answer must match the runtime one — a node advertised Operate that then
// refuses every write would be the silent-no-op failure in a new costume.
var builder = new WriteCapturingBuilder();
await PlainDriver(PlainTag(ReadOnlyPath, writeTopic: null)).DiscoverAsync(builder, default);
builder.Variables[0].SecurityClass.ShouldBe(SecurityClassification.ViewOnly);
}
/// <summary>Minimal capturing builder — this project does not reference Commons' own.</summary>
private sealed class WriteCapturingBuilder : IAddressSpaceBuilder
{
public List<DriverAttributeInfo> Variables { get; } = [];
public IAddressSpaceBuilder Folder(string browseName, string displayName) => this;
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
{
Variables.Add(attributeInfo);
return new Handle(attributeInfo.FullName);
}
public void AddProperty(string browseName, DriverDataType dataType, object? value) { }
private sealed class Handle(string fullRef) : IVariableHandle
{
public string FullReference => fullRef;
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink();
}
private sealed class NullSink : IAlarmConditionSink
{
public void OnTransition(AlarmEventArgs args) { }
}
}
}