using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests;
///
/// Unit tests for 's leg (Gitea #508) — the batch
/// contract and the per-slot refusals, up to (not including) the broker publish.
///
///
/// 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.
///
[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();
[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(
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);
}
/// Minimal capturing builder — this project does not reference Commons' own.
private sealed class WriteCapturingBuilder : IAddressSpaceBuilder
{
public List 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) { }
}
}
}