using Microsoft.Extensions.Logging.Abstractions; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol; using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters; namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.Adapters; public class MxGatewayDataConnectionTests { private static MxGatewayDataConnection NewAdapter(FakeMxGatewayClient fake) => new(fake, NullLogger.Instance); private static Dictionary Details(int writeUserId = 0) => new() { ["Endpoint"] = "http://gw:5000", ["ApiKey"] = "key", ["ClientName"] = "client-a", ["WriteUserId"] = writeUserId.ToString(), ["ReadTimeoutMs"] = "2000", }; // ── Task 6: connect / status / Disconnected ── [Fact] public async Task ConnectAsync_resolves_options_and_sets_status_connected() { var fake = new FakeMxGatewayClient(); var adapter = NewAdapter(fake); await adapter.ConnectAsync(Details(writeUserId: 7)); Assert.Equal(ConnectionHealth.Connected, adapter.Status); Assert.NotNull(fake.ConnectedWith); Assert.Equal("http://gw:5000", fake.ConnectedWith!.Endpoint); Assert.Equal("client-a", fake.ConnectedWith.ClientName); Assert.Equal(7, fake.ConnectedWith.WriteUserId); } [Fact] public async Task ConnectAsync_blank_client_name_defaults_to_scadabridge() { var fake = new FakeMxGatewayClient(); var adapter = NewAdapter(fake); var details = Details(); details["ClientName"] = ""; await adapter.ConnectAsync(details); Assert.Equal("scadabridge", fake.ConnectedWith!.ClientName); } [Fact] public async Task Disconnected_fires_exactly_once_when_event_loop_faults() { var fake = new FakeMxGatewayClient(); var adapter = NewAdapter(fake); int raised = 0; adapter.Disconnected += () => Interlocked.Increment(ref raised); await adapter.ConnectAsync(Details()); // Wait for the event loop to attach. await WaitUntil(() => fake.OnUpdate is not null); fake.FaultEventLoop(); await WaitUntil(() => raised >= 1); Assert.Equal(1, raised); Assert.Equal(ConnectionHealth.Disconnected, adapter.Status); } // ── Task 7: subscribe / unsubscribe + event routing ── [Fact] public async Task Subscribed_tag_update_invokes_callback_with_mapped_TagValue() { var fake = new FakeMxGatewayClient(); var adapter = NewAdapter(fake); await adapter.ConnectAsync(Details()); await WaitUntil(() => fake.OnUpdate is not null); TagValue? received = null; await adapter.SubscribeAsync("Area.Pump.Speed", (_, v) => received = v); Assert.Contains("Area.Pump.Speed", fake.Subscribed); var ts = DateTimeOffset.UtcNow; fake.OnUpdate!(new MxValueUpdate("Area.Pump.Speed", 42.0, QualityCode.Good, ts)); Assert.NotNull(received); Assert.Equal(42.0, received!.Value); Assert.Equal(QualityCode.Good, received.Quality); Assert.Equal(ts, received.Timestamp); } [Fact] public async Task Unsubscribe_stops_routing_updates() { var fake = new FakeMxGatewayClient(); var adapter = NewAdapter(fake); await adapter.ConnectAsync(Details()); await WaitUntil(() => fake.OnUpdate is not null); int hits = 0; var subId = await adapter.SubscribeAsync("T", (_, _) => hits++); await adapter.UnsubscribeAsync(subId); fake.OnUpdate!(new MxValueUpdate("T", 1, QualityCode.Good, DateTimeOffset.UtcNow)); Assert.Equal(0, hits); Assert.Contains(subId, fake.Unsubscribed); } // ── Task 8: read / write + error classification ── [Fact] public async Task ReadAsync_maps_success_and_failure() { var fake = new FakeMxGatewayClient { ReadHandler = tags => tags.Select(t => t == "ok" ? new MxReadOutcome(t, true, 5, QualityCode.Good, DateTimeOffset.UtcNow, null) : new MxReadOutcome(t, false, null, QualityCode.Bad, default, "bad tag")).ToList() }; var adapter = NewAdapter(fake); await adapter.ConnectAsync(Details()); var ok = await adapter.ReadAsync("ok"); Assert.True(ok.Success); Assert.Equal(5, ok.Value!.Value); var bad = await adapter.ReadAsync("nope"); Assert.False(bad.Success); Assert.Equal("bad tag", bad.ErrorMessage); } [Fact] public async Task ReadBatchAsync_returns_dictionary_keyed_by_tag() { var fake = new FakeMxGatewayClient { ReadHandler = tags => tags.Select(t => new MxReadOutcome(t, true, t.Length, QualityCode.Good, DateTimeOffset.UtcNow, null)).ToList() }; var adapter = NewAdapter(fake); await adapter.ConnectAsync(Details()); var results = await adapter.ReadBatchAsync(new[] { "aa", "bbb" }); Assert.Equal(2, results["aa"].Value!.Value); Assert.Equal(3, results["bbb"].Value!.Value); } [Fact] public async Task WriteAsync_maps_failure_to_unsuccessful_WriteResult() { var fake = new FakeMxGatewayClient { WriteHandler = writes => writes.Select(w => new MxWriteOutcome(w.TagPath, false, "rejected")).ToList() }; var adapter = NewAdapter(fake); await adapter.ConnectAsync(Details()); var r = await adapter.WriteAsync("T", 1); Assert.False(r.Success); Assert.Equal("rejected", r.ErrorMessage); } // ── Task 9: WriteBatchAndWait ── [Fact] public async Task WriteBatchAndWait_returns_true_when_response_matches() { var writeCalls = new List(); var fake = new FakeMxGatewayClient { WriteHandler = writes => { writeCalls.AddRange(writes.Select(w => w.TagPath)); return writes.Select(w => new MxWriteOutcome(w.TagPath, true, null)).ToList(); }, // Response path already reads the expected value. ReadHandler = tags => tags.Select(t => new MxReadOutcome(t, true, "DONE", QualityCode.Good, DateTimeOffset.UtcNow, null)).ToList() }; var adapter = NewAdapter(fake); await adapter.ConnectAsync(Details()); var ok = await adapter.WriteBatchAndWaitAsync( new Dictionary { ["V"] = 1 }, "Flag", 1, "Resp", "DONE", TimeSpan.FromSeconds(5)); Assert.True(ok); // Values written before the flag, and the flag itself. Assert.Contains("V", writeCalls); Assert.Contains("Flag", writeCalls); } [Fact] public async Task WriteBatchAndWait_returns_false_on_timeout() { var fake = new FakeMxGatewayClient { WriteHandler = writes => writes.Select(w => new MxWriteOutcome(w.TagPath, true, null)).ToList(), ReadHandler = tags => tags.Select(t => new MxReadOutcome(t, true, "NEVER", QualityCode.Good, DateTimeOffset.UtcNow, null)).ToList() }; var adapter = NewAdapter(fake); await adapter.ConnectAsync(Details()); var ok = await adapter.WriteBatchAndWaitAsync( new Dictionary { ["V"] = 1 }, "Flag", 1, "Resp", "DONE", TimeSpan.FromMilliseconds(300)); Assert.False(ok); } // ── Task 10: browse ── [Fact] public async Task BrowseChildrenAsync_maps_children_and_truncated() { var fake = new FakeMxGatewayClient { BrowseHandler = _ => (new List { new("Area1", "Area1", BrowseNodeClass.Object, true), new("Area1.Pump.Speed", "Speed", BrowseNodeClass.Variable, false), }, true) }; var adapter = NewAdapter(fake); await adapter.ConnectAsync(Details()); var result = await adapter.BrowseChildrenAsync(null); Assert.True(result.Truncated); Assert.Equal(2, result.Children.Count); Assert.Equal(BrowseNodeClass.Object, result.Children[0].NodeClass); Assert.True(result.Children[0].HasChildren); Assert.Equal("Area1.Pump.Speed", result.Children[1].NodeId); Assert.Equal(BrowseNodeClass.Variable, result.Children[1].NodeClass); } [Fact] public async Task BrowseChildrenAsync_throws_when_not_connected() { var fake = new FakeMxGatewayClient(); var adapter = NewAdapter(fake); // Not connected. await Assert.ThrowsAsync( () => adapter.BrowseChildrenAsync(null)); } private static async Task WaitUntil(Func condition, int timeoutMs = 2000) { var sw = System.Diagnostics.Stopwatch.StartNew(); while (!condition() && sw.ElapsedMilliseconds < timeoutMs) await Task.Delay(10); Assert.True(condition(), "Condition not met within timeout."); } }