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; /// /// Tests for — the seam that turns a persisted /// DriverInstance.DriverConfig blob into a live . Mirrors /// OpcUaClientDriverFactoryExtensions' shape (direct deserialization into the options /// record, no intermediate DTO). /// [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(); } /// /// 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 /// ModbusTcp/Modbus incident. /// [Fact] public void DriverTypeName_MatchesConstant() => MqttDriverFactoryExtensions.DriverTypeName.ShouldBe(DriverTypeNames.Mqtt); /// /// 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 /// DriverInstance.DriverType row. /// [Fact] public void DriverTypeName_IsExactlyMqtt() { MqttDriverFactoryExtensions.DriverTypeName.ShouldBe("Mqtt"); DriverTypeNames.Mqtt.ShouldBe("Mqtt"); DriverTypeNames.All.ShouldContain("Mqtt"); } /// /// rawTags 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. /// [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"]); } /// /// 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 from /// makes this throw. /// [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(); } /// /// 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. /// [Fact] public void SharedJsonOptions_CarryTheStringEnumConverter() => MqttJson.Options.Converters.ShouldContain(c => c is JsonStringEnumConverter); [Fact] public void CreateInstance_BlankConfig_Throws() => Should.Throw(() => MqttDriverFactoryExtensions.CreateInstance("d1", " ")); [Fact] public void CreateInstance_JsonNullLiteral_ThrowsInvalidOperation() => Should.Throw(() => MqttDriverFactoryExtensions.CreateInstance("d1", "null")); /// /// Minimal discovery recorder — local to this suite because the driver test project does not /// reference Commons, where the runtime's capturing builder lives (same reason /// MqttDriverDiscoveryTests carries its own). /// private sealed class RecordingBuilder : IAddressSpaceBuilder { public List 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) { } } } }