using System.Text; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests; /// /// Shell-level tests for : authored-only discovery, the lifecycle /// surface (Reinitialize / Shutdown / health / footprint / cache flush), and the /// IReadable 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. /// [Trait("Category", "Unit")] public sealed class MqttDriverDiscoveryTests { /// /// An authored TagConfig blob. dataType uses the real /// member names — there is no Double; the 64-bit float is Float64. /// 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); /// /// Plain mode replays ONLY the authored tag set: one variable per authored raw tag, a /// single discovery pass (), and no online /// discovery — the universal browser must never treat a broker's topic traffic as a /// browsable address space. /// [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(); } /// /// 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. /// [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"]); } /// A raw tag whose TagConfig does not map is skipped, never thrown, and never discovered. [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"]); } /// Discovered MQTT variables are read-only — this driver has no IWritable leg. [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); } /// /// The falsifiability pin for the batch-read contract: a reference that is not an authored /// tag degrades to BadWaitingForInitialData in its own slot rather than throwing and /// failing every other reference in the same batch. /// [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 } /// An empty batch is legal and returns an empty result, not an exception. [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(); } /// /// The falsifiability pin for the cache-flush contract: the last-value cache backs /// IReadable, so flushing it would silently turn every subscribed node Bad under /// memory pressure. FlushOptionalCachesAsync must leave it untouched. /// [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"); } /// A malformed reinitialize delta must never fault the driver, and must not lose the running config. [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"]); } /// /// 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. /// [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"]); } /// Identity is fixed at construction and must match the persisted DriverInstance.DriverType. [Fact] public void Identity_IsMqtt() { var driver = PlainDriver(); driver.DriverType.ShouldBe("Mqtt"); driver.DriverInstanceId.ShouldBe("d"); } /// A driver that has never been initialized reports Unknown, not Healthy. [Fact] public void GetHealth_BeforeInitialize_IsUnknown() { PlainDriver().GetHealth().State.ShouldBe(DriverState.Unknown); } /// Plain mode never signals rediscovery — the authored set only changes by redeploy. [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); } /// The footprint tracks the authored table; an empty driver reports no driver-attributable bytes. [Fact] public void GetMemoryFootprint_TracksAuthoredTable() { PlainDriver().GetMemoryFootprint().ShouldBe(0); PlainDriver(Tag("Plant/Mqtt/dev1/Temp", "f/t")).GetMemoryFootprint().ShouldBeGreaterThan(0); } /// Shutdown on a never-initialized driver is a no-op, not a null-reference. [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); } /// The single-broker connectivity probe names the configured endpoint. [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 ---- /// /// Records the streamed discovery tree instead of materializing OPC UA nodes. Local to this /// suite: the driver test project does not reference Commons, where the runtime's own /// capturing builder lives. Mirrors the per-driver RecordingBuilder the AB CIP / FOCAS /// suites use. /// private sealed class CapturingAddressSpaceBuilder : IAddressSpaceBuilder { /// Every folder streamed, in order, across the whole tree. public List<(string BrowseName, string DisplayName)> Folders { get; } = []; /// Every variable streamed, in order, across the whole tree. public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = []; /// public IAddressSpaceBuilder Folder(string browseName, string displayName) { Folders.Add((browseName, displayName)); return this; } /// public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo) { Variables.Add((browseName, 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) { } } } }