feat(mqtt): factory + DriverTypeNames.Mqtt + host factory/probe registration

Task 9. Adds MqttDriverFactoryExtensions (DriverTypeName = DriverTypeNames.Mqtt,
direct deserialization of MqttDriverOptions — no intermediate DTO, mirroring
OpcUaClientDriverFactoryExtensions), the DriverTypeNames.Mqtt constant, and both
host wiring sites: the factory in DriverFactoryBootstrap.Register and the probe in
AddOtOpcUaDriverProbes (the admin-node path Program.cs calls in its hasAdmin
block — a probe wired only on driver nodes makes Test-connect silently dead).

Carried-forward items:

- Converges every MQTT config-parsing seam onto ONE JsonSerializerOptions —
  MqttJson.Options, in .Contracts alongside MqttDriverOptions. It replaces the
  probe's internal JsonOpts and the browser's separate private copy; the factory,
  the probe, MqttDriver.ParseOptions and MqttDriverBrowser now all parse through
  it. .Contracts is the only assembly all four consumers reference, and the
  browser's reference to the runtime .Driver project is a documented layering
  exception scheduled for removal — anchoring the options there would resurrect
  the duplicate the day it goes away.
- Replaces the "Mqtt" literals in MqttDriverProbe, MqttDriverBrowser and
  MqttDriver with the constant (string value unchanged).
- Tightens MqttDriverProbeTests.ProbeAsync_EnumAsName: it asserted only
  Ok == false + non-empty message, which is also exactly what a JSON-parse
  failure produces — so it stayed green under the very regression it names.
  It now asserts the probe got past the parse and reached the network.

Falsifiability: deleting JsonStringEnumConverter from MqttJson.Options reddens 9
tests across 4 suites, including the tightened probe test (message becomes
"Config JSON is invalid: The JSON value could not be converted to
MqttProtocolVersion") — which the pre-fix assertions would have passed.

Also references the MQTT driver from Core.Abstractions.Tests so
DriverTypeNamesGuardTests' reflective bin scan discovers the new factory and the
constant/factory parity check stays honest.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
This commit is contained in:
Joseph Doherty
2026-07-24 17:24:13 -04:00
parent 36abd871a1
commit f79d13e2d8
12 changed files with 344 additions and 60 deletions
@@ -21,11 +21,12 @@
<ItemGroup>
<ProjectReference Include="..\..\..\src\Core\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
<!-- Core hosts DriverFactoryRegistry; the eight driver assemblies each ship the
<!-- Core hosts DriverFactoryRegistry; the driver assemblies below each ship the
*DriverFactoryExtensions.Register entry point the guard test drives to build the
authoritative registered-factory set (mirrors the Host's DriverFactoryBootstrap). -->
<ProjectReference Include="..\..\..\src\Core\ZB.MOM.WW.OtOpcUa.Core\ZB.MOM.WW.OtOpcUa.Core.csproj"/>
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Modbus\ZB.MOM.WW.OtOpcUa.Driver.Modbus.csproj"/>
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Mqtt\ZB.MOM.WW.OtOpcUa.Driver.Mqtt.csproj"/>
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.S7\ZB.MOM.WW.OtOpcUa.Driver.S7.csproj"/>
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.AbCip\ZB.MOM.WW.OtOpcUa.Driver.AbCip.csproj"/>
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.AbLegacy\ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.csproj"/>
@@ -0,0 +1,151 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests;
/// <summary>
/// Tests for <see cref="MqttDriverFactoryExtensions"/> — the seam that turns a persisted
/// <c>DriverInstance.DriverConfig</c> blob into a live <see cref="MqttDriver"/>. Mirrors
/// <c>OpcUaClientDriverFactoryExtensions</c>' shape (direct deserialization into the options
/// record, no intermediate DTO).
/// </summary>
[Trait("Category", "Unit")]
public sealed class MqttDriverFactoryExtensionsTests
{
[Fact]
public void Register_ThenCreate_BuildsMqttDriver()
{
var registry = new DriverFactoryRegistry();
MqttDriverFactoryExtensions.Register(registry);
var factory = registry.TryGet(MqttDriverFactoryExtensions.DriverTypeName);
factory.ShouldNotBeNull();
var driver = factory("d1", """{"host":"h","port":1883,"mode":"Plain"}""");
driver.ShouldBeOfType<MqttDriver>();
}
/// <summary>
/// The factory's registration key and the canonical constant must be the same string. A
/// drift here is invisible at compile time and silently breaks dispatch — the repo's
/// <c>ModbusTcp</c>/<c>Modbus</c> incident.
/// </summary>
[Fact]
public void DriverTypeName_MatchesConstant()
=> MqttDriverFactoryExtensions.DriverTypeName.ShouldBe(DriverTypeNames.Mqtt);
/// <summary>
/// Third independent pin on the literal itself: both the constant and the factory name could
/// be renamed together and still agree with each other while disagreeing with every persisted
/// <c>DriverInstance.DriverType</c> row.
/// </summary>
[Fact]
public void DriverTypeName_IsExactlyMqtt()
{
MqttDriverFactoryExtensions.DriverTypeName.ShouldBe("Mqtt");
DriverTypeNames.Mqtt.ShouldBe("Mqtt");
DriverTypeNames.All.ShouldContain("Mqtt");
}
/// <summary>
/// <c>rawTags</c> is the ONLY source of the driver's discoverable node set (plain MQTT has no
/// browsable address space), so a factory that parsed everything else correctly but dropped
/// this array would produce a driver that connects, subscribes to nothing and materializes
/// nothing — green everywhere, dark in production.
/// </summary>
[Fact]
public async Task CreateInstance_CarriesRawTagsThroughToDiscovery()
{
const string tagConfig = """{"topic":"f/t","payloadFormat":"Raw","dataType":"String"}""";
var json = JsonSerializer.Serialize(new
{
host = "h",
port = 1883,
mode = "Plain",
rawTags = new[]
{
new { rawPath = "Plant/Mqtt/dev1/Temp", tagConfig, writeIdempotent = false },
},
});
var driver = MqttDriverFactoryExtensions.CreateInstance("d1", json);
var builder = new RecordingBuilder();
await driver.DiscoverAsync(builder, CancellationToken.None);
builder.VariableFullNames.ShouldBe(["Plant/Mqtt/dev1/Temp"]);
}
/// <summary>
/// Enum-valued knobs must bind from their string NAMES — the repo's documented systemic
/// enum-serialization bug is an AdminUI-authored config whose enum field one seam can read
/// and another cannot. Removing <see cref="JsonStringEnumConverter"/> from
/// <see cref="MqttJson.Options"/> makes this throw.
/// </summary>
[Fact]
public void CreateInstance_EnumsAuthoredAsNames_Bind()
{
const string json = """
{"host":"h","port":8883,"mode":"SparkplugB","protocolVersion":"V311",
"sparkplug":{"groupId":"Plant1","hostId":"h1"}}
""";
var driver = MqttDriverFactoryExtensions.CreateInstance("d1", json);
driver.ShouldBeOfType<MqttDriver>();
}
/// <summary>
/// Direct pin on the single shared instance: the enum converter is what the test above
/// exercises behaviourally, and this asserts it structurally so a reader can see the one
/// instance every MQTT config seam parses through.
/// </summary>
[Fact]
public void SharedJsonOptions_CarryTheStringEnumConverter()
=> MqttJson.Options.Converters.ShouldContain(c => c is JsonStringEnumConverter);
[Fact]
public void CreateInstance_BlankConfig_Throws()
=> Should.Throw<ArgumentException>(() => MqttDriverFactoryExtensions.CreateInstance("d1", " "));
[Fact]
public void CreateInstance_JsonNullLiteral_ThrowsInvalidOperation()
=> Should.Throw<InvalidOperationException>(() => MqttDriverFactoryExtensions.CreateInstance("d1", "null"));
/// <summary>
/// Minimal discovery recorder — local to this suite because the driver test project does not
/// reference <c>Commons</c>, where the runtime's capturing builder lives (same reason
/// <c>MqttDriverDiscoveryTests</c> carries its own).
/// </summary>
private sealed class RecordingBuilder : IAddressSpaceBuilder
{
public List<string> VariableFullNames { get; } = [];
public IAddressSpaceBuilder Folder(string browseName, string displayName) => this;
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
{
VariableFullNames.Add(attributeInfo.FullName);
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) { }
}
}
}
@@ -1,5 +1,4 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
@@ -8,12 +7,12 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests;
public sealed class MqttDriverOptionsTests
{
private static readonly JsonSerializerOptions J = new()
{
PropertyNameCaseInsensitive = true,
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
Converters = { new JsonStringEnumConverter() },
};
/// <summary>
/// The production instance, not a look-alike copy. A local clone would keep passing after
/// <see cref="MqttJson.Options"/> lost its <c>JsonStringEnumConverter</c> — testing the copy
/// instead of the thing every seam actually parses through.
/// </summary>
private static readonly JsonSerializerOptions J = MqttJson.Options;
[Fact]
public void Deserialize_SparkplugConfig_ReadsModeAndSubObject()
@@ -161,10 +161,20 @@ public sealed class MqttDriverProbeTests
r.Message.ShouldNotContain(secret);
}
/// <summary>Enum fields (protocolVersion) must round-trip by name, per the repo's documented
/// enum-serialization trap — a numeric-only seam faults AdminUI-authored configs.</summary>
/// <summary>
/// Enum fields (protocolVersion) must round-trip by name, per the repo's documented
/// enum-serialization trap — a numeric-only seam faults AdminUI-authored configs.
/// <para>
/// <b>The assertions are the whole point.</b> "Ok is false with a non-empty message" is
/// ALSO what a JSON-parse failure produces, so asserting only that would stay green if
/// <c>JsonStringEnumConverter</c> were ever dropped from <see cref="MqttJson.Options"/>
/// — i.e. the exact regression this test exists to catch. So it asserts the probe got
/// PAST the parse (message is not the <c>"Config JSON is invalid"</c> shape) and reached
/// the network (the message names the target endpoint).
/// </para>
/// </summary>
[Fact]
public async Task ProbeAsync_EnumAsName_DoesNotThrow()
public async Task ProbeAsync_EnumAsName_ParsesAndReachesTheNetwork()
{
var listener = StartListener();
var port = ListenerPort(listener);
@@ -178,5 +188,14 @@ public sealed class MqttDriverProbeTests
r.Ok.ShouldBeFalse();
r.Message.ShouldNotBeNullOrEmpty();
r.Message.ShouldNotStartWith(
"Config JSON is invalid",
Case.Insensitive,
"the probe never parsed the config — 'V500' was rejected, so the shared JSON options " +
"lost their JsonStringEnumConverter");
r.Message.ShouldNotStartWith("Config JSON deserialized to null");
r.Message.ShouldNotStartWith("Config has no host/port");
// A network-level marker: the probe got as far as dialling the (closed) loopback port.
r.Message.ShouldContain($"127.0.0.1:{port}");
}
}