using Mbproxy.Admin; using Shouldly; using Xunit; namespace Mbproxy.Tests.Admin; /// /// Unit tests for — the tab-keyed, reconnect-safe /// record of which PLC detail pages are open. Includes a concurrency stress test, since /// the tracker is mutated from multiple SignalR hub-dispatch threads. /// [Trait("Category", "Unit")] public sealed class PlcSubscriptionTrackerTests { [Fact] public void Subscribe_ThenRemoveLastConnection_ClearsViewer() { var t = new PlcSubscriptionTracker(); t.SubscribePlc("c1", "tab", "plc"); t.ActivePlcs().ShouldBe(["plc"]); t.RemoveConnection("c1"); t.ActivePlcs().ShouldBeEmpty(); } [Fact] public void SameTab_TwoConnections_StaysActiveUntilLastConnectionGone() { // Reconnect overlap: the same tab briefly holds two connections. Dropping the // old one must not release the tab — this is the leak C2 guards against. var t = new PlcSubscriptionTracker(); t.SubscribePlc("c-old", "tab", "plc"); t.SubscribePlc("c-new", "tab", "plc"); t.RemoveConnection("c-old"); t.ActivePlcs().ShouldContain("plc", "the tab is still alive on the second connection"); t.RemoveConnection("c-new"); t.ActivePlcs().ShouldBeEmpty(); } [Fact] public void SameTab_TwoConnections_RemovedNewestFirst_StaysActiveUntilLast() { // Mirror of SameTab_TwoConnections_StaysActiveUntilLastConnectionGone: the // reconnect's NEW connection is the one that drops first (the order is not // guaranteed). The tab must still be alive on the surviving old connection. var t = new PlcSubscriptionTracker(); t.SubscribePlc("c-old", "tab", "plc"); t.SubscribePlc("c-new", "tab", "plc"); t.RemoveConnection("c-new"); t.ActivePlcs().ShouldContain("plc", "the tab still holds the old connection"); t.RemoveConnection("c-old"); t.ActivePlcs().ShouldBeEmpty(); } [Fact] public void DistinctTabs_AreCountedSeparately() { var t = new PlcSubscriptionTracker(); t.SubscribePlc("c1", "tab-A", "plc"); t.SubscribePlc("c2", "tab-B", "plc"); t.RemoveConnection("c1"); t.ActivePlcs().ShouldContain("plc", "the second tab still views the PLC"); t.RemoveConnection("c2"); t.ActivePlcs().ShouldBeEmpty(); } [Fact] public void RepeatedSubscribe_SameTabSamePlc_IsIdempotent() { var t = new PlcSubscriptionTracker(); t.SubscribePlc("c1", "tab", "plc"); t.SubscribePlc("c1", "tab", "plc"); // redundant repeat t.ActivePlcs().ShouldBe(["plc"]); t.RemoveConnection("c1"); t.ActivePlcs().ShouldBeEmpty("a repeated subscribe must not inflate the viewer count"); } [Fact] public void OneConnection_MultiplePlcs_AllReleasedTogether() { var t = new PlcSubscriptionTracker(); t.SubscribePlc("c1", "tab", "plc-a"); t.SubscribePlc("c1", "tab", "plc-b"); t.ActivePlcs().Count.ShouldBe(2); t.RemoveConnection("c1"); t.ActivePlcs().ShouldBeEmpty(); } [Fact] public void RemoveConnection_Unknown_IsNoOp() { var t = new PlcSubscriptionTracker(); Should.NotThrow(() => t.RemoveConnection("never-seen")); t.ActivePlcs().ShouldBeEmpty(); } [Fact] public async Task ConcurrentSubscribeAndRemove_NeverLeaksOrThrows() { var t = new PlcSubscriptionTracker(); const int tasks = 16; const int iterations = 5_000; await Task.WhenAll(Enumerable.Range(0, tasks).Select(taskNo => Task.Run(() => { for (int i = 0; i < iterations; i++) { string conn = $"c{taskNo}-{i}"; string tab = $"tab{taskNo}-{i}"; t.SubscribePlc(conn, tab, "plc"); t.RemoveConnection(conn); } }, TestContext.Current.CancellationToken))); t.ActivePlcs().ShouldBeEmpty( "every subscribe was paired with a remove — no viewer count may leak"); } }