feat(mqtt): MqttDriver shell — lifecycle + authored-only discovery (Once)
Composes MqttConnection + MqttSubscriptionManager + LastValueCache into the IDriver / ITagDiscovery / ISubscribable / IReadable / IHostConnectivityProbe / IRediscoverable capability set. - Discovery replays ONLY the authored raw tags (RediscoverPolicy = Once, SupportsOnlineDiscovery = false) — a chatty broker can never auto-provision. - Composition order is register -> AttachTo -> ConnectAsync; the manager's Reconnected handler is passed through unwrapped so its throw-on-total-failure still tears a deaf session down. - ReinitializeAsync applies a tag-only delta in place and never faults on a bad one; a session-changing delta rebuilds. - ReadAsync serves the last-value cache and degrades per reference (BadWaitingForInitialData), never throwing for the batch. - FlushOptionalCachesAsync is a no-op: the last-value cache IS IReadable. - Adds MqttDriverOptions.RawTags (the authored-tag delivery mechanism every other driver's options DTO already has) and promotes MaxPayloadBytes from a manager ctor knob to an operator-facing key. - Converges on MqttDriverProbe.JsonOpts rather than a second, divergent JsonSerializerOptions. Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
This commit is contained in:
@@ -0,0 +1,305 @@
|
||||
using System.Text;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Shell-level tests for <see cref="MqttDriver"/>: authored-only discovery, the lifecycle
|
||||
/// surface (<c>Reinitialize</c> / <c>Shutdown</c> / health / footprint / cache flush), and the
|
||||
/// <c>IReadable</c> batch contract. Nothing here dials a broker — every test exercises the
|
||||
/// connection-free half of the driver, which is exactly the half a chatty broker must not be
|
||||
/// able to influence.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class MqttDriverDiscoveryTests
|
||||
{
|
||||
/// <summary>
|
||||
/// An authored TagConfig blob. <c>dataType</c> uses the real <see cref="DriverDataType"/>
|
||||
/// member names — there is no <c>Double</c>; the 64-bit float is <c>Float64</c>.
|
||||
/// </summary>
|
||||
private static string TagJson(string topic, string dataType = "String", string payloadFormat = "Raw")
|
||||
=> $$"""{"topic":"{{topic}}","payloadFormat":"{{payloadFormat}}","dataType":"{{dataType}}"}""";
|
||||
|
||||
private static RawTagEntry Tag(string rawPath, string topic, string dataType = "String")
|
||||
=> new(rawPath, TagJson(topic, dataType), WriteIdempotent: false);
|
||||
|
||||
private static MqttDriver PlainDriver(params RawTagEntry[] tags)
|
||||
=> new(new MqttDriverOptions { Mode = MqttMode.Plain, RawTags = tags }, "d", null);
|
||||
|
||||
/// <summary>
|
||||
/// Plain mode replays ONLY the authored tag set: one variable per authored raw tag, a
|
||||
/// single discovery pass (<see cref="DiscoveryRediscoverPolicy.Once"/>), and no online
|
||||
/// discovery — the universal browser must never treat a broker's topic traffic as a
|
||||
/// browsable address space.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task DiscoverAsync_Plain_StreamsAuthoredTagsOnly_PolicyOnce()
|
||||
{
|
||||
var driver = PlainDriver(Tag("Plant/Mqtt/dev1/Temp", "f/t"));
|
||||
var b = new CapturingAddressSpaceBuilder();
|
||||
|
||||
await driver.DiscoverAsync(b, CancellationToken.None);
|
||||
|
||||
b.Variables.Count.ShouldBe(1);
|
||||
b.Variables[0].Info.FullName.ShouldBe("Plant/Mqtt/dev1/Temp");
|
||||
driver.RediscoverPolicy.ShouldBe(DiscoveryRediscoverPolicy.Once);
|
||||
((ITagDiscovery)driver).SupportsOnlineDiscovery.ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The falsifiability pin for authored-only discovery: a message that arrives on a topic no
|
||||
/// authored tag names must not add anything to the discovered set. A driver that
|
||||
/// auto-provisioned from broker traffic would grow the address space on every deploy against
|
||||
/// a chatty broker.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task DiscoverAsync_UnauthoredBrokerTraffic_DoesNotAppear()
|
||||
{
|
||||
var driver = PlainDriver(Tag("Plant/Mqtt/dev1/Temp", "f/t"));
|
||||
|
||||
// Traffic this driver never authored — a neighbouring publisher on the same broker.
|
||||
driver.Subscriptions.HandleMessage("some/other/topic", Encoding.UTF8.GetBytes("42"), retained: false);
|
||||
driver.Subscriptions.HandleMessage("f/t/deeper", Encoding.UTF8.GetBytes("42"), retained: false);
|
||||
|
||||
var b = new CapturingAddressSpaceBuilder();
|
||||
await driver.DiscoverAsync(b, CancellationToken.None);
|
||||
|
||||
b.Variables.Select(v => v.Info.FullName).ShouldBe(["Plant/Mqtt/dev1/Temp"]);
|
||||
}
|
||||
|
||||
/// <summary>A raw tag whose TagConfig does not map is skipped, never thrown, and never discovered.</summary>
|
||||
[Fact]
|
||||
public async Task DiscoverAsync_SkipsUnmappableTagConfig()
|
||||
{
|
||||
var driver = PlainDriver(
|
||||
Tag("Plant/Mqtt/dev1/Good", "f/t"),
|
||||
new RawTagEntry("Plant/Mqtt/dev1/Bad", "not-json", WriteIdempotent: false));
|
||||
|
||||
var b = new CapturingAddressSpaceBuilder();
|
||||
await driver.DiscoverAsync(b, CancellationToken.None);
|
||||
|
||||
b.Variables.Select(v => v.Info.FullName).ShouldBe(["Plant/Mqtt/dev1/Good"]);
|
||||
}
|
||||
|
||||
/// <summary>Discovered MQTT variables are read-only — this driver has no <c>IWritable</c> leg.</summary>
|
||||
[Fact]
|
||||
public async Task DiscoverAsync_MarksVariablesViewOnly()
|
||||
{
|
||||
var driver = PlainDriver(Tag("Plant/Mqtt/dev1/Temp", "f/t", "Float64"));
|
||||
var b = new CapturingAddressSpaceBuilder();
|
||||
|
||||
await driver.DiscoverAsync(b, CancellationToken.None);
|
||||
|
||||
b.Variables[0].Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly);
|
||||
b.Variables[0].Info.DriverDataType.ShouldBe(DriverDataType.Float64);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The falsifiability pin for the batch-read contract: a reference that is not an authored
|
||||
/// tag degrades to <c>BadWaitingForInitialData</c> in its own slot rather than throwing and
|
||||
/// failing every other reference in the same batch.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ReadAsync_UnknownReference_DoesNotThrow_AndDegradesPerRef()
|
||||
{
|
||||
var driver = PlainDriver(Tag("Plant/Mqtt/dev1/Temp", "f/t"));
|
||||
driver.Subscriptions.HandleMessage("f/t", Encoding.UTF8.GetBytes("hot"), retained: false);
|
||||
|
||||
var results = await driver.ReadAsync(
|
||||
["Plant/Mqtt/dev1/Temp", "Plant/Mqtt/dev1/NeverAuthored"],
|
||||
CancellationToken.None);
|
||||
|
||||
results.Count.ShouldBe(2);
|
||||
results[0].StatusCode.ShouldBe(0u);
|
||||
results[0].Value.ShouldBe("hot");
|
||||
results[1].StatusCode.ShouldBe(0x80320000u); // BadWaitingForInitialData
|
||||
}
|
||||
|
||||
/// <summary>An empty batch is legal and returns an empty result, not an exception.</summary>
|
||||
[Fact]
|
||||
public async Task ReadAsync_EmptyBatch_ReturnsEmpty()
|
||||
{
|
||||
var driver = PlainDriver(Tag("Plant/Mqtt/dev1/Temp", "f/t"));
|
||||
|
||||
var results = await driver.ReadAsync([], CancellationToken.None);
|
||||
|
||||
results.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The falsifiability pin for the cache-flush contract: the last-value cache backs
|
||||
/// <c>IReadable</c>, so flushing it would silently turn every subscribed node Bad under
|
||||
/// memory pressure. <c>FlushOptionalCachesAsync</c> must leave it untouched.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task FlushOptionalCachesAsync_LeavesLastValueCacheIntact()
|
||||
{
|
||||
var driver = PlainDriver(Tag("Plant/Mqtt/dev1/Temp", "f/t"));
|
||||
driver.Subscriptions.HandleMessage("f/t", Encoding.UTF8.GetBytes("hot"), retained: false);
|
||||
|
||||
await driver.FlushOptionalCachesAsync(CancellationToken.None);
|
||||
|
||||
var results = await driver.ReadAsync(["Plant/Mqtt/dev1/Temp"], CancellationToken.None);
|
||||
results[0].StatusCode.ShouldBe(0u);
|
||||
results[0].Value.ShouldBe("hot");
|
||||
}
|
||||
|
||||
/// <summary>A malformed reinitialize delta must never fault the driver, and must not lose the running config.</summary>
|
||||
[Fact]
|
||||
public async Task ReinitializeAsync_MalformedDelta_KeepsRunningConfig_AndDoesNotFault()
|
||||
{
|
||||
var driver = PlainDriver(Tag("Plant/Mqtt/dev1/Temp", "f/t"));
|
||||
|
||||
await Should.NotThrowAsync(() => driver.ReinitializeAsync("{ this is not json", CancellationToken.None));
|
||||
|
||||
driver.GetHealth().State.ShouldNotBe(DriverState.Faulted);
|
||||
|
||||
var b = new CapturingAddressSpaceBuilder();
|
||||
await driver.DiscoverAsync(b, CancellationToken.None);
|
||||
b.Variables.Select(v => v.Info.FullName).ShouldBe(["Plant/Mqtt/dev1/Temp"]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A delta that changes only the authored tag set is applied in place — no teardown, no
|
||||
/// reconnect — and is applied WHOLESALE: a tag the redeploy dropped stops being discovered.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ReinitializeAsync_TagOnlyDelta_AppliedInPlace_Wholesale()
|
||||
{
|
||||
var driver = PlainDriver(Tag("Plant/Mqtt/dev1/Temp", "f/t"));
|
||||
|
||||
var pressure = System.Text.Json.JsonSerializer.Serialize(TagJson("f/p"));
|
||||
var flow = System.Text.Json.JsonSerializer.Serialize(TagJson("f/q"));
|
||||
var delta = $$"""
|
||||
{
|
||||
"mode": "Plain",
|
||||
"rawTags": [
|
||||
{ "rawPath": "Plant/Mqtt/dev1/Pressure", "tagConfig": {{pressure}}, "writeIdempotent": false },
|
||||
{ "rawPath": "Plant/Mqtt/dev1/Flow", "tagConfig": {{flow}}, "writeIdempotent": false }
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
await driver.ReinitializeAsync(delta, CancellationToken.None);
|
||||
|
||||
var b = new CapturingAddressSpaceBuilder();
|
||||
await driver.DiscoverAsync(b, CancellationToken.None);
|
||||
|
||||
b.Variables.Select(v => v.Info.FullName)
|
||||
.OrderBy(x => x, StringComparer.Ordinal)
|
||||
.ShouldBe(["Plant/Mqtt/dev1/Flow", "Plant/Mqtt/dev1/Pressure"]);
|
||||
}
|
||||
|
||||
/// <summary>Identity is fixed at construction and must match the persisted <c>DriverInstance.DriverType</c>.</summary>
|
||||
[Fact]
|
||||
public void Identity_IsMqtt()
|
||||
{
|
||||
var driver = PlainDriver();
|
||||
|
||||
driver.DriverType.ShouldBe("Mqtt");
|
||||
driver.DriverInstanceId.ShouldBe("d");
|
||||
}
|
||||
|
||||
/// <summary>A driver that has never been initialized reports Unknown, not Healthy.</summary>
|
||||
[Fact]
|
||||
public void GetHealth_BeforeInitialize_IsUnknown()
|
||||
{
|
||||
PlainDriver().GetHealth().State.ShouldBe(DriverState.Unknown);
|
||||
}
|
||||
|
||||
/// <summary>Plain mode never signals rediscovery — the authored set only changes by redeploy.</summary>
|
||||
[Fact]
|
||||
public async Task Plain_NeverRaisesRediscoveryNeeded()
|
||||
{
|
||||
var driver = PlainDriver(Tag("Plant/Mqtt/dev1/Temp", "f/t"));
|
||||
var fired = 0;
|
||||
((IRediscoverable)driver).OnRediscoveryNeeded += (_, _) => Interlocked.Increment(ref fired);
|
||||
|
||||
driver.Subscriptions.HandleMessage("f/t", Encoding.UTF8.GetBytes("hot"), retained: false);
|
||||
await driver.DiscoverAsync(new CapturingAddressSpaceBuilder(), CancellationToken.None);
|
||||
|
||||
fired.ShouldBe(0);
|
||||
}
|
||||
|
||||
/// <summary>The footprint tracks the authored table; an empty driver reports no driver-attributable bytes.</summary>
|
||||
[Fact]
|
||||
public void GetMemoryFootprint_TracksAuthoredTable()
|
||||
{
|
||||
PlainDriver().GetMemoryFootprint().ShouldBe(0);
|
||||
PlainDriver(Tag("Plant/Mqtt/dev1/Temp", "f/t")).GetMemoryFootprint().ShouldBeGreaterThan(0);
|
||||
}
|
||||
|
||||
/// <summary>Shutdown on a never-initialized driver is a no-op, not a null-reference.</summary>
|
||||
[Fact]
|
||||
public async Task ShutdownAsync_BeforeInitialize_DoesNotThrow()
|
||||
{
|
||||
var driver = PlainDriver(Tag("Plant/Mqtt/dev1/Temp", "f/t"));
|
||||
|
||||
await Should.NotThrowAsync(() => driver.ShutdownAsync(CancellationToken.None));
|
||||
driver.GetHealth().State.ShouldBe(DriverState.Unknown);
|
||||
}
|
||||
|
||||
/// <summary>The single-broker connectivity probe names the configured endpoint.</summary>
|
||||
[Fact]
|
||||
public void GetHostStatuses_ReportsTheConfiguredBroker()
|
||||
{
|
||||
var driver = new MqttDriver(
|
||||
new MqttDriverOptions { Mode = MqttMode.Plain, Host = "broker.example", Port = 1883 },
|
||||
"d",
|
||||
null);
|
||||
|
||||
var statuses = ((IHostConnectivityProbe)driver).GetHostStatuses();
|
||||
|
||||
statuses.Count.ShouldBe(1);
|
||||
statuses[0].HostName.ShouldBe("broker.example:1883");
|
||||
statuses[0].State.ShouldBe(HostState.Unknown);
|
||||
}
|
||||
|
||||
// ---- test doubles ----
|
||||
|
||||
/// <summary>
|
||||
/// Records the streamed discovery tree instead of materializing OPC UA nodes. Local to this
|
||||
/// suite: the driver test project does not reference <c>Commons</c>, where the runtime's own
|
||||
/// capturing builder lives. Mirrors the per-driver <c>RecordingBuilder</c> the AB CIP / FOCAS
|
||||
/// suites use.
|
||||
/// </summary>
|
||||
private sealed class CapturingAddressSpaceBuilder : IAddressSpaceBuilder
|
||||
{
|
||||
/// <summary>Every folder streamed, in order, across the whole tree.</summary>
|
||||
public List<(string BrowseName, string DisplayName)> Folders { get; } = [];
|
||||
|
||||
/// <summary>Every variable streamed, in order, across the whole tree.</summary>
|
||||
public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = [];
|
||||
|
||||
/// <inheritdoc />
|
||||
public IAddressSpaceBuilder Folder(string browseName, string displayName)
|
||||
{
|
||||
Folders.Add((browseName, displayName));
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
|
||||
{
|
||||
Variables.Add((browseName, attributeInfo));
|
||||
return new Handle(attributeInfo.FullName);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user