using System.Text; using Google.Protobuf; using Org.Eclipse.Tahu.Protobuf; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Sparkplug; using TahuDataType = Org.Eclipse.Tahu.Protobuf.DataType; 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); /// /// Options aimed at a definitely-closed port so a connect is refused immediately rather /// than hanging — the tests that must prove a rebuild actually dials need the dial to fail /// fast, not to burn a connect deadline. /// private static MqttDriverOptions ClosedPortOptions(params RawTagEntry[] tags) => new() { Mode = MqttMode.Plain, Host = "127.0.0.1", Port = 1, UseTls = false, ConnectTimeoutSeconds = 2, RawTags = tags, }; private static MqttDriver ClosedPortDriver(params RawTagEntry[] tags) => new(ClosedPortOptions(tags), "d", null); /// /// Serializes a full options record as the reinitialize blob. Round-tripping the whole record /// (rather than hand-writing a partial JSON object) guarantees that only the field the caller /// changed differs — a hand-written delta silently omitting a session field would take the /// rebuild branch for the wrong reason and the test would pass vacuously. /// private static string DeltaJson(MqttDriverOptions options) => System.Text.Json.JsonSerializer.Serialize(options, MqttJson.Options); /// /// 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"]); } /// /// A delta that changes the broker endpoint takes the rebuild branch: it must actually /// dial the new endpoint (proven here by the refused connect), and a rebuild whose connect /// fails must land rather than letting the failure escape /// with the health surface still claiming Healthy. /// [Fact] public async Task ReinitializeAsync_SessionChangingDelta_Rebuilds_AndFaultsOnUnreachableBroker() { var driver = ClosedPortDriver(Tag("Plant/Mqtt/dev1/Temp", "f/t")); // Only Host differs from the ctor options — everything else round-trips identically. var delta = DeltaJson(ClosedPortOptions() with { Host = "127.0.0.2" }); await Should.ThrowAsync(() => driver.ReinitializeAsync(delta, CancellationToken.None)); driver.GetHealth().State.ShouldBe(DriverState.Faulted); } /// /// Pins that IngestIdentity is nested inside SessionIdentity: a delta touching /// ONLY an ingest setting still rebuilds. Without the nesting it would be judged "same /// session", applied in place, and the subscription manager that actually reads /// MaxPayloadBytes would never be rebuilt — a config change that silently does nothing. /// [Fact] public async Task ReinitializeAsync_IngestOnlyDelta_AlsoRebuilds() { var driver = ClosedPortDriver(Tag("Plant/Mqtt/dev1/Temp", "f/t")); var delta = DeltaJson(ClosedPortOptions() with { MaxPayloadBytes = 4096 }); // Reaching the (refused) connect at all is the proof the rebuild branch was taken; the // tag-only test above is the control that shows an in-place delta never dials. await Should.ThrowAsync(() => driver.ReinitializeAsync(delta, CancellationToken.None)); } /// /// The falsifiability pin for the orphaned-connection class: /// must serialize behind an in-flight lifecycle operation, not race past it. /// /// /// An ungated dispose running while a rebuild holds the gate observes _connection == null /// (the rebuild has torn the old session down but not yet assigned the new one), does nothing, /// and sets the disposed flag — after which the rebuild assigns a live connection plus a /// host-probe loop that no later dispose can reach. Here the driver is parked inside /// InitializeCoreAsync at the pre-connect hook; the assertion is that dispose does not /// complete while it is parked, and does complete once it is released. /// [Fact] public async Task DisposeAsync_SerializesBehindAnInFlightLifecycleOperation() { var driver = ClosedPortDriver(Tag("Plant/Mqtt/dev1/Temp", "f/t")); var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); driver.BeforeConnectHookForTests = async _ => { entered.TrySetResult(); await release.Task; }; var initialize = Task.Run(() => driver.InitializeAsync("", CancellationToken.None)); await entered.Task.WaitAsync(TimeSpan.FromSeconds(5)); var dispose = driver.DisposeAsync().AsTask(); // The gate is held by the parked initialize. Dispose must still be waiting. (The gate wait is // bounded by ConnectTimeoutSeconds = 2 s, so this 300 ms window is comfortably inside it — // an ungated dispose returns essentially instantly.) var raced = await Task.WhenAny(dispose, Task.Delay(TimeSpan.FromMilliseconds(300))); raced.ShouldNotBe(dispose, "DisposeAsync returned while a lifecycle operation held the gate"); release.SetResult(); await Should.ThrowAsync(() => initialize); // connect refused on the closed port await dispose.WaitAsync(TimeSpan.FromSeconds(10)); dispose.IsCompletedSuccessfully.ShouldBeTrue(); } /// 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); } // ================================================================================= // Task 22 — Sparkplug discovery policy, birth-filled datatypes, rediscovery trigger // ================================================================================= /// /// Sparkplug's discovered shape is not fully known at connect: a tag authored without a /// dataType takes its type from the birth certificate, which arrives asynchronously. /// That is precisely the contract — the /// host re-runs discovery until the captured set settles. /// [Fact] public void SparkplugMode_RediscoverPolicy_IsUntilStable() => SparkplugDriver().RediscoverPolicy.ShouldBe(DiscoveryRediscoverPolicy.UntilStable); /// /// The mode gate, asserted from the other side: plain MQTT's authored set is fully known /// synchronously, so it must stay . A blanket flip /// to UntilStable would make every plain deployment re-run discovery on a timer for a /// tree that can never change. /// [Fact] public void PlainMode_RediscoverPolicy_StaysOnce() => PlainDriver(Tag("Plant/Mqtt/dev1/Temp", "f/t")).RediscoverPolicy.ShouldBe(DiscoveryRediscoverPolicy.Once); /// /// A DBIRTH for an authored device the driver has never held a birth for introduces the /// metrics' datatypes, so the discovered tree genuinely changed: rediscovery fires. /// [Fact] public void NewDbirth_FiresOnRediscoveryNeeded() { var driver = SparkplugDriver(SpTag(SpTempPath, SpBlob(SpDevice, "Temperature"))); var fired = 0; ((IRediscoverable)driver).OnRediscoveryNeeded += (_, _) => Interlocked.Increment(ref fired); FeedNodeBirth(driver); FeedDeviceBirth(driver, seq: 1, ("Temperature", 5UL, TahuDataType.Float)); fired.ShouldBeGreaterThan(0); } /// /// The primary anti-storm pin. An edge node that re-births on a timer (or a flapping /// connection driving the late-join rebirth) republishes the same metric set over and over. If /// every birth fired rediscovery, each one would trigger an address-space rebuild — expensive /// and disruptive on a running server, forever, for a tree that never changed. /// [Fact] public void IdenticalRebirth_DoesNotFireRediscovery() { var driver = SparkplugDriver(SpTag(SpTempPath, SpBlob(SpDevice, "Temperature"))); FeedNodeBirth(driver); FeedDeviceBirth(driver, seq: 1, ("Temperature", 5UL, TahuDataType.Float)); // Subscribe only AFTER the first birth so the count below is purely the rebirths'. var fired = 0; ((IRediscoverable)driver).OnRediscoveryNeeded += (_, _) => Interlocked.Increment(ref fired); for (var i = 0; i < 5; i++) { FeedNodeBirth(driver, seq: 0, bdSeq: (ulong)(i + 2)); FeedDeviceBirth(driver, seq: 1, ("Temperature", 5UL, TahuDataType.Float)); } fired.ShouldBe(0); } /// /// The other half of the gate: a rebirth that actually changes the metric set (a metric added, /// removed or renamed) DOES change what discovery can resolve, and must fire. /// [Fact] public void RebirthWithChangedMetricSet_FiresRediscovery() { var driver = SparkplugDriver( SpTag(SpTempPath, SpBlob(SpDevice, "Temperature")), SpTag(SpPressPath, SpBlob(SpDevice, "Pressure"))); FeedNodeBirth(driver); FeedDeviceBirth(driver, seq: 1, ("Temperature", 5UL, TahuDataType.Float)); var fired = 0; ((IRediscoverable)driver).OnRediscoveryNeeded += (_, _) => Interlocked.Increment(ref fired); FeedDeviceBirth(driver, seq: 2, ("Temperature", 5UL, TahuDataType.Float), ("Pressure", 6UL, TahuDataType.Float)); fired.ShouldBe(1); } /// /// Metric ORDER is not the metric SET. A birth that lists the same metrics in a different order /// describes the same address space; firing on it would reintroduce the rebuild storm through /// the back door, because nothing obliges an edge node to keep its birth order stable. /// [Fact] public void RebirthReorderingTheSameMetrics_DoesNotFireRediscovery() { var driver = SparkplugDriver( SpTag(SpTempPath, SpBlob(SpDevice, "Temperature")), SpTag(SpPressPath, SpBlob(SpDevice, "Pressure"))); FeedNodeBirth(driver); FeedDeviceBirth(driver, seq: 1, ("Temperature", 5UL, TahuDataType.Float), ("Pressure", 6UL, TahuDataType.Float)); var fired = 0; ((IRediscoverable)driver).OnRediscoveryNeeded += (_, _) => Interlocked.Increment(ref fired); FeedDeviceBirth(driver, seq: 2, ("Pressure", 6UL, TahuDataType.Float), ("Temperature", 5UL, TahuDataType.Float)); fired.ShouldBe(0); } /// /// A birth for a device NO authored tag names cannot change 's /// output — discovery replays the authored set. A large plant DBIRTHs devices this deployment /// never reads on their own schedules; firing for those is a rebuild storm sourced from traffic /// the configuration has no opinion about. /// [Fact] public void BirthForAnUnauthoredDevice_DoesNotFireRediscovery() { var driver = SparkplugDriver(SpTag(SpTempPath, SpBlob(SpDevice, "Temperature"))); FeedNodeBirth(driver); var fired = 0; ((IRediscoverable)driver).OnRediscoveryNeeded += (_, _) => Interlocked.Increment(ref fired); // Same (authored) edge node, a device nothing binds — the ingestor still applies the birth. FeedDeviceBirth(driver, "Filler99", seq: 1, ("Temperature", 5UL, TahuDataType.Float)); fired.ShouldBe(0); } /// /// The rediscovery event names the subtree this driver's discovery actually emits (the /// Mqtt folder) so a consumer scoping a rebuild on it scopes to exactly the tree that /// changed, and carries the Sparkplug scope in the diagnostic reason. /// [Fact] public void RediscoveryEvent_ScopeHintIsTheDiscoveryFolder_ReasonNamesTheScope() { var driver = SparkplugDriver(SpTag(SpTempPath, SpBlob(SpDevice, "Temperature"))); RediscoveryEventArgs? args = null; ((IRediscoverable)driver).OnRediscoveryNeeded += (_, e) => args = e; FeedNodeBirth(driver); FeedDeviceBirth(driver, seq: 1, ("Temperature", 5UL, TahuDataType.Float)); args.ShouldNotBeNull(); args.ScopeHint.ShouldBe("Mqtt"); args.Reason.ShouldContain($"{SpGroup}/{SpNode}/{SpDevice}"); } /// /// The datatype fill-in. dataType is optional in the Sparkplug tag shape — the /// birth certificate declares it. Before any birth the tag can only report the record default; /// once the DBIRTH lands, discovery must re-stream it with the type the birth declared. /// [Fact] public async Task DiscoverAsync_Sparkplug_UnauthoredDataType_FillsInFromTheBirth() { var driver = SparkplugDriver(SpTag(SpTempPath, SpBlob(SpDevice, "Temperature"))); var before = new CapturingAddressSpaceBuilder(); await driver.DiscoverAsync(before, TestContext.Current.CancellationToken); // The node exists from the first pass — an authored tag is part of the declared configuration, // not something the plant grants by publishing. before.Variables.Select(v => v.Info.FullName).ShouldBe([SpTempPath]); before.Variables[0].Info.DriverDataType.ShouldBe(DriverDataType.String); // the record default FeedNodeBirth(driver); FeedDeviceBirth(driver, seq: 1, ("Temperature", 5UL, TahuDataType.Float)); var after = new CapturingAddressSpaceBuilder(); await driver.DiscoverAsync(after, TestContext.Current.CancellationToken); after.Variables.Select(v => v.Info.FullName).ShouldBe([SpTempPath]); after.Variables[0].Info.DriverDataType.ShouldBe(DriverDataType.Float32); } /// /// The authored dataType wins over the birth's — the same precedence the ingest path /// applies when it coerces a value (DataTypeAuthored ? authored : birth). A discovery /// surface that disagreed with the publish surface would report a type no value ever arrives as. /// [Fact] public async Task DiscoverAsync_Sparkplug_AuthoredDataType_WinsOverTheBirth() { var driver = SparkplugDriver(SpTag(SpTempPath, SpBlob(SpDevice, "Temperature", "Int32"))); FeedNodeBirth(driver); FeedDeviceBirth(driver, seq: 1, ("Temperature", 5UL, TahuDataType.Float)); var b = new CapturingAddressSpaceBuilder(); await driver.DiscoverAsync(b, TestContext.Current.CancellationToken); b.Variables[0].Info.DriverDataType.ShouldBe(DriverDataType.Int32); } /// /// A birth declaring a Sparkplug type this driver cannot map (DataSet / Template / PropertySet /// / Unknown) must not blank the tag's type — ToDriverDataType() returns null for those, /// and discovery falls back to the authored/default type rather than to nothing. /// [Fact] public async Task DiscoverAsync_Sparkplug_UnsupportedBirthType_FallsBackToTheDefault() { var driver = SparkplugDriver(SpTag(SpTempPath, SpBlob(SpDevice, "Temperature"))); FeedNodeBirth(driver); FeedDeviceBirth(driver, seq: 1, ("Temperature", 5UL, TahuDataType.DataSet)); var b = new CapturingAddressSpaceBuilder(); await driver.DiscoverAsync(b, TestContext.Current.CancellationToken); b.Variables.Count.ShouldBe(1); b.Variables[0].Info.DriverDataType.ShouldBe(DriverDataType.String); } /// /// The composition-root line. Task 21 wires the ingestor's OnDataChange to the /// driver's own in CreateSparkplugIngestor; every other Sparkplug test in the repo drives /// directly and would pass with that lambda deleted. This repo /// has twice shipped a component correct in isolation and inert in production for exactly that /// reason (DeferredAddressSpaceSink, GatewayTagProvisioner) — so the re-raise is /// asserted through 's own event, under the RawPath. /// [Fact] public async Task SparkplugMode_DriverRepublishesIngestDataChanges_UnderTheRawPath() { var driver = SparkplugDriver(SpTag(SpTempPath, SpBlob(SpDevice, "Temperature", "Float32"))); await driver.SubscribeAsync([SpTempPath], TimeSpan.Zero, TestContext.Current.CancellationToken); object? sender = null; string? firedRef = null; object? firedValue = null; ((ISubscribable)driver).OnDataChange += (s, e) => { sender = s; firedRef = e.FullReference; firedValue = e.Snapshot.Value; }; FeedNodeBirth(driver); FeedDeviceBirth(driver, seq: 1, ("Temperature", 5UL, TahuDataType.Float)); FeedDeviceData(driver, seq: 2, (5UL, 21.5f)); firedRef.ShouldBe(SpTempPath); firedValue.ShouldBe(21.5f); // The driver re-raises with ITSELF as sender, not the ingestor — DriverHostActor attributes // publishes to the driver instance. sender.ShouldBeSameAs(driver); } /// /// ReadAsync serves from the driver-owned LastValueCache, which the Sparkplug /// ingestor is handed at construction. If the two ever stopped sharing one instance, every /// Sparkplug read would answer BadWaitingForInitialData forever while subscriptions /// looked perfectly healthy. /// [Fact] public async Task SparkplugMode_ReadAsyncServesTheIngestedValue() { var driver = SparkplugDriver(SpTag(SpTempPath, SpBlob(SpDevice, "Temperature", "Float32"))); FeedNodeBirth(driver); FeedDeviceBirth(driver, seq: 1, ("Temperature", 5UL, TahuDataType.Float)); FeedDeviceData(driver, seq: 2, (5UL, 33.5f)); var results = await driver.ReadAsync([SpTempPath], TestContext.Current.CancellationToken); results[0].Value.ShouldBe(33.5f); results[0].StatusCode.ShouldBe(0x00000000u); } /// /// Subscribe/unsubscribe dispatch by mode. After an unsubscribe the reference is no longer /// attributed to a handle, so ingest stops raising for it — while the cache keeps answering, so /// a read still works. Both halves matter: a driver that kept publishing after unsubscribe /// would leak notifications into a torn-down monitored item. /// [Fact] public async Task SparkplugMode_UnsubscribeStopsNotifications_ButNotReads() { var driver = SparkplugDriver(SpTag(SpTempPath, SpBlob(SpDevice, "Temperature", "Float32"))); var handle = await driver.SubscribeAsync([SpTempPath], TimeSpan.Zero, TestContext.Current.CancellationToken); FeedNodeBirth(driver); FeedDeviceBirth(driver, seq: 1, ("Temperature", 5UL, TahuDataType.Float)); var fired = 0; ((ISubscribable)driver).OnDataChange += (_, _) => Interlocked.Increment(ref fired); await driver.UnsubscribeAsync(handle, TestContext.Current.CancellationToken); FeedDeviceData(driver, seq: 2, (5UL, 7.5f)); fired.ShouldBe(0); (await driver.ReadAsync([SpTempPath], TestContext.Current.CancellationToken))[0].Value.ShouldBe(7.5f); } /// /// The mode gate from the other side — the reviewer's reading of the branching, turned into a /// test. A Plain-mode driver builds NO Sparkplug ingestor at all, so no Sparkplug traffic can /// reach it and a Sparkplug-shaped blob (which carries no topic) resolves to nothing. /// [Fact] public void PlainMode_BuildsNoSparkplugIngestor() { var driver = new MqttDriver( new MqttDriverOptions { Mode = MqttMode.Plain, RawTags = [SpTag(SpTempPath, SpBlob(SpDevice, "Temperature", "Float32"))], }, "d", null); driver.Sparkplug.ShouldBeNull(); driver.Subscriptions.TryResolve(SpTempPath, out _).ShouldBeFalse(); } /// /// …and the converse: a Sparkplug-mode driver registers into the Sparkplug path, not the plain /// manager. Registering into both would double-warn on every deploy and, worse, let a plain /// topic route a Sparkplug tag. /// [Fact] public void SparkplugMode_RegistersIntoTheSparkplugPathOnly() { var driver = SparkplugDriver(SpTag(SpTempPath, SpBlob(SpDevice, "Temperature", "Float32"))); driver.Sparkplug!.TryResolve(SpTempPath, out var def).ShouldBeTrue(); def.MetricName.ShouldBe("Temperature"); driver.Subscriptions.TryResolve(SpTempPath, out _).ShouldBeFalse(); } // ---- Sparkplug fixtures ---- private const string SpGroup = "Plant1"; private const string SpNode = "EdgeA"; private const string SpDevice = "Filler1"; private const string SpTempPath = "Plant/Mqtt/spb/Filler1Temp"; private const string SpPressPath = "Plant/Mqtt/spb/Filler1Press"; /// A Sparkplug tag blob. dataType is omitted unless supplied — it is optional. private static string SpBlob(string? device, string metric, string? dataType = null) { var deviceKey = device is null ? "" : $"""{'"'}deviceId{'"'}:"{device}","""; var typeKey = dataType is null ? "" : $""","dataType":"{dataType}" """; return $$"""{"groupId":"{{SpGroup}}","edgeNodeId":"{{SpNode}}",{{deviceKey}}"metricName":"{{metric}}"{{typeKey}}}"""; } private static RawTagEntry SpTag(string rawPath, string blob) => new(rawPath, blob, WriteIdempotent: false); private static MqttDriver SparkplugDriver(params RawTagEntry[] tags) => new( new MqttDriverOptions { Mode = MqttMode.SparkplugB, Sparkplug = new MqttSparkplugOptions { GroupId = SpGroup }, RawTags = tags, }, "d", null); /// /// Feeds a real NBIRTH through the real codec + ingest state machine — no broker, and no /// test-only shortcut on the driver. Dispatch is the method MQTTnet's dispatcher thread /// reaches, so this exercises the production path end to end. /// private static void FeedNodeBirth(MqttDriver driver, ulong seq = 0, ulong bdSeq = 1) { var payload = new Payload { Seq = seq, Timestamp = 1721822400000UL }; payload.Metrics.Add(new Payload.Types.Metric { Name = "bdSeq", Datatype = (uint)TahuDataType.Uint64, LongValue = bdSeq, }); payload.Metrics.Add(SpBirthMetric("Uptime", 7UL, TahuDataType.Int64)); driver.Sparkplug!.Dispatch( new SparkplugTopic(SparkplugMessageType.NBIRTH, SpGroup, SpNode, null, null), SparkplugCodec.Decode(payload.ToByteArray())); } private static void FeedDeviceBirth( MqttDriver driver, ulong seq, params (string Name, ulong Alias, TahuDataType Type)[] metrics) => FeedDeviceBirth(driver, SpDevice, seq, metrics); private static void FeedDeviceBirth( MqttDriver driver, string device, ulong seq, params (string Name, ulong Alias, TahuDataType Type)[] metrics) { var payload = new Payload { Seq = seq, Timestamp = 1721822400000UL }; foreach (var (name, alias, type) in metrics) { payload.Metrics.Add(SpBirthMetric(name, alias, type)); } driver.Sparkplug!.Dispatch( new SparkplugTopic(SparkplugMessageType.DBIRTH, SpGroup, SpNode, device, null), SparkplugCodec.Decode(payload.ToByteArray())); } /// /// Feeds a real DDATA — alias-only metrics, exactly as a Sparkplug node publishes after a birth. /// private static void FeedDeviceData(MqttDriver driver, ulong seq, params (ulong Alias, float Value)[] metrics) { var payload = new Payload { Seq = seq, Timestamp = 1721822405000UL }; foreach (var (alias, value) in metrics) { payload.Metrics.Add(new Payload.Types.Metric { Alias = alias, FloatValue = value }); } driver.Sparkplug!.Dispatch( new SparkplugTopic(SparkplugMessageType.DDATA, SpGroup, SpNode, SpDevice, null), SparkplugCodec.Decode(payload.ToByteArray())); } /// /// A birth metric declaring name/alias/datatype and no value — a birth's job here is to /// declare the catalog, and these tests assert on the catalog, never on a published value. /// private static Payload.Types.Metric SpBirthMetric(string name, ulong alias, TahuDataType type) => new() { Name = name, Alias = alias, Datatype = (uint)type }; // ---- 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) { } } } }