using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using Shouldly; using Xunit; using ZB.MOM.WW.LmxOpcUa.Host.Configuration; using ZB.MOM.WW.LmxOpcUa.Host.Domain; using ZB.MOM.WW.LmxOpcUa.Host.Metrics; using ZB.MOM.WW.LmxOpcUa.Host.MxAccess; using ZB.MOM.WW.LmxOpcUa.Tests.Helpers; namespace ZB.MOM.WW.LmxOpcUa.Tests.MxAccess { /// /// Verifies how the MXAccess client manages persistent subscriptions, reconnect replay, and probe-tag behavior. /// public class MxAccessClientSubscriptionTests : IDisposable { private readonly StaComThread _staThread; private readonly FakeMxProxy _proxy; private readonly PerformanceMetrics _metrics; private readonly MxAccessClient _client; /// /// Initializes the subscription test fixture with a fake runtime proxy and STA thread. /// public MxAccessClientSubscriptionTests() { _staThread = new StaComThread(); _staThread.Start(); _proxy = new FakeMxProxy(); _metrics = new PerformanceMetrics(); _client = new MxAccessClient(_staThread, _proxy, new MxAccessConfiguration(), _metrics); } /// /// Disposes the subscription test fixture and its supporting resources. /// public void Dispose() { _client.Dispose(); _staThread.Dispose(); _metrics.Dispose(); } /// /// Confirms that subscribing creates a runtime item, advises it, and increments the active subscription count. /// [Fact] public async Task Subscribe_CreatesItemAndAdvises() { await _client.ConnectAsync(); await _client.SubscribeAsync("TestTag.Attr", (_, _) => { }); _proxy.Items.Count.ShouldBeGreaterThan(0); _proxy.AdvisedItems.Count.ShouldBeGreaterThan(0); _client.ActiveSubscriptionCount.ShouldBe(1); } /// /// Confirms that subscribing to the same address twice reuses the existing runtime item. /// [Fact] public async Task Subscribe_SameAddressTwice_ReusesExistingRuntimeItem() { await _client.ConnectAsync(); await _client.SubscribeAsync("TestTag.Attr", (_, _) => { }); await _client.SubscribeAsync("TestTag.Attr", (_, _) => { }); _client.ActiveSubscriptionCount.ShouldBe(1); _proxy.Items.Values.Count(v => v == "TestTag.Attr").ShouldBe(1); await _client.UnsubscribeAsync("TestTag.Attr"); _proxy.Items.Values.ShouldNotContain("TestTag.Attr"); } /// /// Confirms that unsubscribing clears the active subscription count after a tag was previously monitored. /// [Fact] public async Task Unsubscribe_RemovesItemAndUnadvises() { await _client.ConnectAsync(); await _client.SubscribeAsync("TestTag.Attr", (_, _) => { }); await _client.UnsubscribeAsync("TestTag.Attr"); _client.ActiveSubscriptionCount.ShouldBe(0); } /// /// Confirms that runtime data changes are delivered to the per-subscription callback. /// [Fact] public async Task OnDataChange_InvokesCallback() { await _client.ConnectAsync(); Vtq? received = null; await _client.SubscribeAsync("TestTag.Attr", (addr, vtq) => received = vtq); _proxy.SimulateDataChangeByAddress("TestTag.Attr", 42, 192); received.ShouldNotBeNull(); received.Value.Value.ShouldBe(42); received.Value.Quality.ShouldBe(Quality.Good); } /// /// Confirms that runtime data changes are also delivered to the client's global tag-change event. /// [Fact] public async Task OnDataChange_InvokesGlobalHandler() { await _client.ConnectAsync(); string? globalAddr = null; _client.OnTagValueChanged += (addr, vtq) => globalAddr = addr; await _client.SubscribeAsync("TestTag.Attr", (_, _) => { }); _proxy.SimulateDataChangeByAddress("TestTag.Attr", "hello", 192); globalAddr.ShouldBe("TestTag.Attr"); } /// /// Confirms that stored subscriptions are replayed after reconnect so live updates resume automatically. /// [Fact] public async Task StoredSubscriptions_ReplayedAfterReconnect() { await _client.ConnectAsync(); var callbackInvoked = false; await _client.SubscribeAsync("TestTag.Attr", (_, _) => callbackInvoked = true); // Reconnect await _client.ReconnectAsync(); // After reconnect, subscription should be replayed _client.ActiveSubscriptionCount.ShouldBe(1); // Simulate data change on the re-subscribed item _proxy.SimulateDataChangeByAddress("TestTag.Attr", "value", 192); callbackInvoked.ShouldBe(true); } /// /// Confirms that one-shot reads do not remove persistent subscriptions when the client reconnects. /// [Fact] public async Task OneShotRead_DoesNotRemovePersistentSubscription_OnReconnect() { await _client.ConnectAsync(); var callbackInvoked = false; await _client.SubscribeAsync("TestTag.Attr", (_, _) => callbackInvoked = true); var readTask = _client.ReadAsync("TestTag.Attr"); await Task.Delay(50); _proxy.SimulateDataChangeByAddress("TestTag.Attr", 42, 192); (await readTask).Value.ShouldBe(42); callbackInvoked = false; await _client.ReconnectAsync(); _proxy.SimulateDataChangeByAddress("TestTag.Attr", "after_reconnect", 192); callbackInvoked.ShouldBe(true); _client.ActiveSubscriptionCount.ShouldBe(1); } /// /// Confirms that transient writes do not prevent later removal of a persistent subscription. /// [Fact] public async Task OneShotWrite_DoesNotBreakPersistentUnsubscribe() { await _client.ConnectAsync(); await _client.SubscribeAsync("TestTag.Attr", (_, _) => { }); _proxy.Items.Values.ShouldContain("TestTag.Attr"); var writeResult = await _client.WriteAsync("TestTag.Attr", 7); writeResult.ShouldBe(true); await _client.UnsubscribeAsync("TestTag.Attr"); _client.ActiveSubscriptionCount.ShouldBe(0); _proxy.Items.Values.ShouldNotContain("TestTag.Attr"); } /// /// Confirms that the configured probe tag is subscribed during connect so connectivity monitoring can start immediately. /// [Fact] public async Task ProbeTag_SubscribedOnConnect() { var proxy = new FakeMxProxy(); var config = new MxAccessConfiguration { ProbeTag = "TestProbe" }; var client = new MxAccessClient(_staThread, proxy, config, _metrics); await client.ConnectAsync(); // Probe tag should be subscribed (present in proxy items) proxy.Items.Values.ShouldContain("TestProbe"); client.Dispose(); } /// /// Confirms that the probe tag cannot be unsubscribed accidentally because it is reserved for connection monitoring. /// [Fact] public async Task ProbeTag_ProtectedFromUnsubscribe() { var proxy = new FakeMxProxy(); var config = new MxAccessConfiguration { ProbeTag = "TestProbe" }; var client = new MxAccessClient(_staThread, proxy, config, _metrics); await client.ConnectAsync(); proxy.Items.Values.ShouldContain("TestProbe"); // Attempt to unsubscribe the probe tag — should be protected await client.UnsubscribeAsync("TestProbe"); // Probe should still be in the proxy items (not removed) proxy.Items.Values.ShouldContain("TestProbe"); client.Dispose(); } } }