0a693e0be9
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).
260 lines
9.0 KiB
C#
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.");
|
|
}
|
|
}
|