Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/MxGatewayDataConnectionTests.cs
T
Joseph Doherty 0a693e0be9 feat(dcl): MxGatewayDataConnection adapter (connect/subscribe/read/write/wait/browse)
Implements IDataConnection + IBrowsableDataConnection over the IMxGatewayClient
seam: connect/disconnect with once-only Disconnected guard + background event
loop, subscribe/unsubscribe with tag routing, read/write batch with per-tag
error classification, WriteBatchAndWait, and Galaxy browse mapping. Covers plan
Tasks 6-10. Full unit coverage via FakeMxGatewayClient (12 tests).
2026-05-29 07:50:16 -04:00

260 lines
9.0 KiB
C#

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<MxGatewayDataConnection>.Instance);
private static Dictionary<string, string> 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<string>();
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<string, object?> { ["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<string, object?> { ["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<MxBrowseChild>
{
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<ConnectionNotConnectedException>(
() => adapter.BrowseChildrenAsync(null));
}
private static async Task WaitUntil(Func<bool> 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.");
}
}