feat(lmxproxy): phase 5 — client core (ILmxProxyClient, connection, read/write/subscribe)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,122 @@
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.LmxProxy.Client.Tests;
|
||||
|
||||
public class ClientMetricsTests
|
||||
{
|
||||
private static LmxProxyClient.ClientMetrics CreateMetrics() => new();
|
||||
|
||||
[Fact]
|
||||
public void IncrementOperationCount_Increments()
|
||||
{
|
||||
var metrics = CreateMetrics();
|
||||
|
||||
metrics.IncrementOperationCount("Read");
|
||||
metrics.IncrementOperationCount("Read");
|
||||
metrics.IncrementOperationCount("Read");
|
||||
|
||||
var snapshot = metrics.GetSnapshot();
|
||||
snapshot["Read_count"].Should().Be(3L);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IncrementErrorCount_Increments()
|
||||
{
|
||||
var metrics = CreateMetrics();
|
||||
|
||||
metrics.IncrementErrorCount("Write");
|
||||
metrics.IncrementErrorCount("Write");
|
||||
|
||||
var snapshot = metrics.GetSnapshot();
|
||||
snapshot["Write_errors"].Should().Be(2L);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecordLatency_StoresValues()
|
||||
{
|
||||
var metrics = CreateMetrics();
|
||||
|
||||
metrics.RecordLatency("Read", 10);
|
||||
metrics.RecordLatency("Read", 20);
|
||||
metrics.RecordLatency("Read", 30);
|
||||
|
||||
var snapshot = metrics.GetSnapshot();
|
||||
snapshot.Should().ContainKey("Read_avg_latency_ms");
|
||||
snapshot.Should().ContainKey("Read_p95_latency_ms");
|
||||
snapshot.Should().ContainKey("Read_p99_latency_ms");
|
||||
|
||||
var avg = (double)snapshot["Read_avg_latency_ms"];
|
||||
avg.Should().BeApproximately(20.0, 0.1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RollingBuffer_CapsAt1000()
|
||||
{
|
||||
var metrics = CreateMetrics();
|
||||
|
||||
for (int i = 0; i < 1100; i++)
|
||||
{
|
||||
metrics.RecordLatency("Read", i);
|
||||
}
|
||||
|
||||
var snapshot = metrics.GetSnapshot();
|
||||
// After 1100 entries, the buffer should have capped at 1000 (oldest removed)
|
||||
// The earliest remaining value should be 100 (entries 0-99 were evicted)
|
||||
var p95 = (long)snapshot["Read_p95_latency_ms"];
|
||||
// p95 of values 100-1099 should be around 1050
|
||||
p95.Should().BeGreaterThan(900);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetSnapshot_IncludesP95AndP99()
|
||||
{
|
||||
var metrics = CreateMetrics();
|
||||
|
||||
// Add 100 values: 1, 2, 3, ..., 100
|
||||
for (int i = 1; i <= 100; i++)
|
||||
{
|
||||
metrics.RecordLatency("Op", i);
|
||||
}
|
||||
|
||||
var snapshot = metrics.GetSnapshot();
|
||||
|
||||
var p95 = (long)snapshot["Op_p95_latency_ms"];
|
||||
var p99 = (long)snapshot["Op_p99_latency_ms"];
|
||||
|
||||
// P95 of 1..100 should be 95
|
||||
p95.Should().Be(95);
|
||||
// P99 of 1..100 should be 99
|
||||
p99.Should().Be(99);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetSnapshot_ReturnsEmptyForNoData()
|
||||
{
|
||||
var metrics = CreateMetrics();
|
||||
|
||||
var snapshot = metrics.GetSnapshot();
|
||||
|
||||
snapshot.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetSnapshot_TracksMultipleOperations()
|
||||
{
|
||||
var metrics = CreateMetrics();
|
||||
|
||||
metrics.IncrementOperationCount("Read");
|
||||
metrics.IncrementOperationCount("Write");
|
||||
metrics.IncrementErrorCount("Read");
|
||||
metrics.RecordLatency("Read", 10);
|
||||
metrics.RecordLatency("Write", 20);
|
||||
|
||||
var snapshot = metrics.GetSnapshot();
|
||||
|
||||
snapshot["Read_count"].Should().Be(1L);
|
||||
snapshot["Write_count"].Should().Be(1L);
|
||||
snapshot["Read_errors"].Should().Be(1L);
|
||||
snapshot.Should().ContainKey("Read_avg_latency_ms");
|
||||
snapshot.Should().ContainKey("Write_avg_latency_ms");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using ZB.MOM.WW.LmxProxy.Client.Domain;
|
||||
|
||||
namespace ZB.MOM.WW.LmxProxy.Client.Tests.Fakes;
|
||||
|
||||
/// <summary>
|
||||
/// Hand-written fake implementation of IScadaService for unit testing.
|
||||
/// </summary>
|
||||
internal class FakeScadaService : IScadaService
|
||||
{
|
||||
// Configure responses
|
||||
public ConnectResponse ConnectResponseToReturn { get; set; } = new() { Success = true, SessionId = "test-session-123", Message = "OK" };
|
||||
public DisconnectResponse DisconnectResponseToReturn { get; set; } = new() { Success = true, Message = "OK" };
|
||||
public GetConnectionStateResponse GetConnectionStateResponseToReturn { get; set; } = new() { IsConnected = true };
|
||||
public ReadResponse ReadResponseToReturn { get; set; } = new() { Success = true };
|
||||
public ReadBatchResponse ReadBatchResponseToReturn { get; set; } = new() { Success = true };
|
||||
public WriteResponse WriteResponseToReturn { get; set; } = new() { Success = true };
|
||||
public WriteBatchResponse WriteBatchResponseToReturn { get; set; } = new() { Success = true };
|
||||
public WriteBatchAndWaitResponse WriteBatchAndWaitResponseToReturn { get; set; } = new() { Success = true };
|
||||
public CheckApiKeyResponse CheckApiKeyResponseToReturn { get; set; } = new() { IsValid = true, Message = "Valid" };
|
||||
|
||||
// Track calls
|
||||
public List<ConnectRequest> ConnectCalls { get; } = [];
|
||||
public List<DisconnectRequest> DisconnectCalls { get; } = [];
|
||||
public List<GetConnectionStateRequest> GetConnectionStateCalls { get; } = [];
|
||||
public List<ReadRequest> ReadCalls { get; } = [];
|
||||
public List<ReadBatchRequest> ReadBatchCalls { get; } = [];
|
||||
public List<WriteRequest> WriteCalls { get; } = [];
|
||||
public List<WriteBatchRequest> WriteBatchCalls { get; } = [];
|
||||
public List<WriteBatchAndWaitRequest> WriteBatchAndWaitCalls { get; } = [];
|
||||
public List<CheckApiKeyRequest> CheckApiKeyCalls { get; } = [];
|
||||
public List<SubscribeRequest> SubscribeCalls { get; } = [];
|
||||
|
||||
// Error injection
|
||||
public Exception? GetConnectionStateException { get; set; }
|
||||
|
||||
// Subscription data
|
||||
public List<VtqMessage> SubscriptionMessages { get; set; } = [];
|
||||
public Exception? SubscriptionException { get; set; }
|
||||
|
||||
public ValueTask<ConnectResponse> ConnectAsync(ConnectRequest request)
|
||||
{
|
||||
ConnectCalls.Add(request);
|
||||
return new ValueTask<ConnectResponse>(ConnectResponseToReturn);
|
||||
}
|
||||
|
||||
public ValueTask<DisconnectResponse> DisconnectAsync(DisconnectRequest request)
|
||||
{
|
||||
DisconnectCalls.Add(request);
|
||||
return new ValueTask<DisconnectResponse>(DisconnectResponseToReturn);
|
||||
}
|
||||
|
||||
public ValueTask<GetConnectionStateResponse> GetConnectionStateAsync(GetConnectionStateRequest request)
|
||||
{
|
||||
GetConnectionStateCalls.Add(request);
|
||||
if (GetConnectionStateException is not null)
|
||||
throw GetConnectionStateException;
|
||||
return new ValueTask<GetConnectionStateResponse>(GetConnectionStateResponseToReturn);
|
||||
}
|
||||
|
||||
public ValueTask<ReadResponse> ReadAsync(ReadRequest request)
|
||||
{
|
||||
ReadCalls.Add(request);
|
||||
return new ValueTask<ReadResponse>(ReadResponseToReturn);
|
||||
}
|
||||
|
||||
public ValueTask<ReadBatchResponse> ReadBatchAsync(ReadBatchRequest request)
|
||||
{
|
||||
ReadBatchCalls.Add(request);
|
||||
return new ValueTask<ReadBatchResponse>(ReadBatchResponseToReturn);
|
||||
}
|
||||
|
||||
public ValueTask<WriteResponse> WriteAsync(WriteRequest request)
|
||||
{
|
||||
WriteCalls.Add(request);
|
||||
return new ValueTask<WriteResponse>(WriteResponseToReturn);
|
||||
}
|
||||
|
||||
public ValueTask<WriteBatchResponse> WriteBatchAsync(WriteBatchRequest request)
|
||||
{
|
||||
WriteBatchCalls.Add(request);
|
||||
return new ValueTask<WriteBatchResponse>(WriteBatchResponseToReturn);
|
||||
}
|
||||
|
||||
public ValueTask<WriteBatchAndWaitResponse> WriteBatchAndWaitAsync(WriteBatchAndWaitRequest request)
|
||||
{
|
||||
WriteBatchAndWaitCalls.Add(request);
|
||||
return new ValueTask<WriteBatchAndWaitResponse>(WriteBatchAndWaitResponseToReturn);
|
||||
}
|
||||
|
||||
public ValueTask<CheckApiKeyResponse> CheckApiKeyAsync(CheckApiKeyRequest request)
|
||||
{
|
||||
CheckApiKeyCalls.Add(request);
|
||||
return new ValueTask<CheckApiKeyResponse>(CheckApiKeyResponseToReturn);
|
||||
}
|
||||
|
||||
public async IAsyncEnumerable<VtqMessage> SubscribeAsync(
|
||||
SubscribeRequest request, [EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
SubscribeCalls.Add(request);
|
||||
|
||||
foreach (var msg in SubscriptionMessages)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
yield return msg;
|
||||
await Task.Yield();
|
||||
}
|
||||
|
||||
if (SubscriptionException is not null)
|
||||
throw SubscriptionException;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using ZB.MOM.WW.LmxProxy.Client.Domain;
|
||||
|
||||
namespace ZB.MOM.WW.LmxProxy.Client.Tests.Fakes;
|
||||
|
||||
/// <summary>
|
||||
/// Helper to create an LmxProxyClient wired to a FakeScadaService, bypassing real gRPC.
|
||||
/// Uses reflection to set private fields since the client has no test seam for IScadaService injection.
|
||||
/// </summary>
|
||||
internal static class TestableClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates an LmxProxyClient with a fake service injected into its internal state,
|
||||
/// simulating a connected client.
|
||||
/// </summary>
|
||||
public static (LmxProxyClient Client, FakeScadaService Fake) CreateConnected(
|
||||
string sessionId = "test-session-123",
|
||||
ILogger<LmxProxyClient>? logger = null)
|
||||
{
|
||||
var fake = new FakeScadaService
|
||||
{
|
||||
ConnectResponseToReturn = new ConnectResponse
|
||||
{
|
||||
Success = true,
|
||||
SessionId = sessionId,
|
||||
Message = "OK"
|
||||
}
|
||||
};
|
||||
|
||||
var client = new LmxProxyClient("localhost", 50051, "test-key", null, logger);
|
||||
|
||||
// Use reflection to inject fake service and simulate connected state
|
||||
var clientType = typeof(LmxProxyClient);
|
||||
|
||||
var clientField = clientType.GetField("_client",
|
||||
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!;
|
||||
clientField.SetValue(client, fake);
|
||||
|
||||
var sessionField = clientType.GetField("_sessionId",
|
||||
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!;
|
||||
sessionField.SetValue(client, sessionId);
|
||||
|
||||
var connectedField = clientType.GetField("_isConnected",
|
||||
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!;
|
||||
connectedField.SetValue(client, true);
|
||||
|
||||
return (client, fake);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.LmxProxy.Client.Domain;
|
||||
using ZB.MOM.WW.LmxProxy.Client.Tests.Fakes;
|
||||
|
||||
namespace ZB.MOM.WW.LmxProxy.Client.Tests;
|
||||
|
||||
public class LmxProxyClientConnectionTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task IsConnectedAsync_ReturnsFalseBeforeConnect()
|
||||
{
|
||||
var client = new LmxProxyClient("localhost", 50051, null, null);
|
||||
|
||||
var result = await client.IsConnectedAsync();
|
||||
|
||||
result.Should().BeFalse();
|
||||
client.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IsConnectedAsync_ReturnsTrueAfterInjection()
|
||||
{
|
||||
var (client, _) = TestableClient.CreateConnected();
|
||||
|
||||
var result = await client.IsConnectedAsync();
|
||||
|
||||
result.Should().BeTrue();
|
||||
client.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DisconnectAsync_SendsDisconnectAndClearsState()
|
||||
{
|
||||
var (client, fake) = TestableClient.CreateConnected();
|
||||
|
||||
await client.DisconnectAsync();
|
||||
|
||||
fake.DisconnectCalls.Should().HaveCount(1);
|
||||
fake.DisconnectCalls[0].SessionId.Should().Be("test-session-123");
|
||||
client.IsConnected.Should().BeFalse();
|
||||
client.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DisconnectAsync_SwallowsExceptions()
|
||||
{
|
||||
var (client, fake) = TestableClient.CreateConnected();
|
||||
fake.DisconnectResponseToReturn = null!; // Force an error path
|
||||
|
||||
// Should not throw
|
||||
var act = () => client.DisconnectAsync();
|
||||
await act.Should().NotThrowAsync();
|
||||
|
||||
client.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsConnected_ReturnsFalseAfterDispose()
|
||||
{
|
||||
var (client, _) = TestableClient.CreateConnected();
|
||||
|
||||
client.Dispose();
|
||||
|
||||
client.IsConnected.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MarkDisconnectedAsync_ClearsConnectionState()
|
||||
{
|
||||
var (client, _) = TestableClient.CreateConnected();
|
||||
|
||||
await client.MarkDisconnectedAsync(new Exception("connection lost"));
|
||||
|
||||
client.IsConnected.Should().BeFalse();
|
||||
client.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultTimeout_RejectsOutOfRange()
|
||||
{
|
||||
var client = new LmxProxyClient("localhost", 50051, null, null);
|
||||
|
||||
var act = () => client.DefaultTimeout = TimeSpan.FromMilliseconds(500);
|
||||
act.Should().Throw<ArgumentOutOfRangeException>();
|
||||
|
||||
var act2 = () => client.DefaultTimeout = TimeSpan.FromMinutes(11);
|
||||
act2.Should().Throw<ArgumentOutOfRangeException>();
|
||||
|
||||
client.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultTimeout_AcceptsValidRange()
|
||||
{
|
||||
var client = new LmxProxyClient("localhost", 50051, null, null);
|
||||
|
||||
client.DefaultTimeout = TimeSpan.FromSeconds(5);
|
||||
client.DefaultTimeout.Should().Be(TimeSpan.FromSeconds(5));
|
||||
|
||||
client.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.LmxProxy.Client.Domain;
|
||||
using ZB.MOM.WW.LmxProxy.Client.Tests.Fakes;
|
||||
|
||||
namespace ZB.MOM.WW.LmxProxy.Client.Tests;
|
||||
|
||||
public class LmxProxyClientReadWriteTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ReadAsync_ReturnsVtqFromResponse()
|
||||
{
|
||||
var (client, fake) = TestableClient.CreateConnected();
|
||||
fake.ReadResponseToReturn = new ReadResponse
|
||||
{
|
||||
Success = true,
|
||||
Vtq = new VtqMessage
|
||||
{
|
||||
Tag = "TestTag",
|
||||
Value = new TypedValue { DoubleValue = 42.5 },
|
||||
TimestampUtcTicks = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc).Ticks,
|
||||
Quality = new QualityCode { StatusCode = 0x00000000 }
|
||||
}
|
||||
};
|
||||
|
||||
var result = await client.ReadAsync("TestTag");
|
||||
|
||||
result.Value.Should().Be(42.5);
|
||||
result.Quality.Should().Be(Quality.Good);
|
||||
fake.ReadCalls.Should().HaveCount(1);
|
||||
fake.ReadCalls[0].Tag.Should().Be("TestTag");
|
||||
fake.ReadCalls[0].SessionId.Should().Be("test-session-123");
|
||||
client.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadAsync_ThrowsOnFailureResponse()
|
||||
{
|
||||
var (client, fake) = TestableClient.CreateConnected();
|
||||
fake.ReadResponseToReturn = new ReadResponse { Success = false, Message = "Tag not found" };
|
||||
|
||||
var act = () => client.ReadAsync("BadTag");
|
||||
|
||||
await act.Should().ThrowAsync<InvalidOperationException>()
|
||||
.WithMessage("*Tag not found*");
|
||||
client.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadAsync_ThrowsWhenNotConnected()
|
||||
{
|
||||
var client = new LmxProxyClient("localhost", 50051, null, null);
|
||||
|
||||
var act = () => client.ReadAsync("AnyTag");
|
||||
|
||||
await act.Should().ThrowAsync<InvalidOperationException>()
|
||||
.WithMessage("*not connected*");
|
||||
client.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadBatchAsync_ReturnsDictionaryOfVtqs()
|
||||
{
|
||||
var (client, fake) = TestableClient.CreateConnected();
|
||||
fake.ReadBatchResponseToReturn = new ReadBatchResponse
|
||||
{
|
||||
Success = true,
|
||||
Vtqs =
|
||||
[
|
||||
new VtqMessage
|
||||
{
|
||||
Tag = "Tag1",
|
||||
Value = new TypedValue { Int32Value = 100 },
|
||||
TimestampUtcTicks = DateTime.UtcNow.Ticks,
|
||||
Quality = new QualityCode { StatusCode = 0x00000000 }
|
||||
},
|
||||
new VtqMessage
|
||||
{
|
||||
Tag = "Tag2",
|
||||
Value = new TypedValue { BoolValue = true },
|
||||
TimestampUtcTicks = DateTime.UtcNow.Ticks,
|
||||
Quality = new QualityCode { StatusCode = 0x00000000 }
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var result = await client.ReadBatchAsync(["Tag1", "Tag2"]);
|
||||
|
||||
result.Should().HaveCount(2);
|
||||
result["Tag1"].Value.Should().Be(100);
|
||||
result["Tag2"].Value.Should().Be(true);
|
||||
client.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WriteAsync_SendsTypedValueDirectly()
|
||||
{
|
||||
var (client, fake) = TestableClient.CreateConnected();
|
||||
var typedValue = new TypedValue { DoubleValue = 99.9 };
|
||||
|
||||
await client.WriteAsync("TestTag", typedValue);
|
||||
|
||||
fake.WriteCalls.Should().HaveCount(1);
|
||||
fake.WriteCalls[0].Tag.Should().Be("TestTag");
|
||||
fake.WriteCalls[0].Value.Should().NotBeNull();
|
||||
fake.WriteCalls[0].Value!.DoubleValue.Should().Be(99.9);
|
||||
client.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WriteAsync_ThrowsOnFailureResponse()
|
||||
{
|
||||
var (client, fake) = TestableClient.CreateConnected();
|
||||
fake.WriteResponseToReturn = new WriteResponse { Success = false, Message = "Write error" };
|
||||
|
||||
var act = () => client.WriteAsync("Tag", new TypedValue { Int32Value = 1 });
|
||||
|
||||
await act.Should().ThrowAsync<InvalidOperationException>()
|
||||
.WithMessage("*Write error*");
|
||||
client.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WriteBatchAsync_SendsAllItems()
|
||||
{
|
||||
var (client, fake) = TestableClient.CreateConnected();
|
||||
var values = new Dictionary<string, TypedValue>
|
||||
{
|
||||
["Tag1"] = new TypedValue { DoubleValue = 1.0 },
|
||||
["Tag2"] = new TypedValue { Int32Value = 2 },
|
||||
["Tag3"] = new TypedValue { BoolValue = true }
|
||||
};
|
||||
|
||||
await client.WriteBatchAsync(values);
|
||||
|
||||
fake.WriteBatchCalls.Should().HaveCount(1);
|
||||
fake.WriteBatchCalls[0].Items.Should().HaveCount(3);
|
||||
client.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WriteBatchAndWaitAsync_ReturnsResponse()
|
||||
{
|
||||
var (client, fake) = TestableClient.CreateConnected();
|
||||
fake.WriteBatchAndWaitResponseToReturn = new WriteBatchAndWaitResponse
|
||||
{
|
||||
Success = true,
|
||||
FlagReached = true,
|
||||
ElapsedMs = 150,
|
||||
WriteResults = [new WriteResult { Tag = "Tag1", Success = true }]
|
||||
};
|
||||
var values = new Dictionary<string, TypedValue>
|
||||
{
|
||||
["Tag1"] = new TypedValue { Int32Value = 1 }
|
||||
};
|
||||
|
||||
var result = await client.WriteBatchAndWaitAsync(
|
||||
values, "FlagTag", new TypedValue { BoolValue = true });
|
||||
|
||||
result.FlagReached.Should().BeTrue();
|
||||
result.ElapsedMs.Should().Be(150);
|
||||
client.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckApiKeyAsync_ReturnsApiKeyInfo()
|
||||
{
|
||||
var (client, fake) = TestableClient.CreateConnected();
|
||||
fake.CheckApiKeyResponseToReturn = new CheckApiKeyResponse { IsValid = true, Message = "Admin key" };
|
||||
|
||||
var result = await client.CheckApiKeyAsync("my-api-key");
|
||||
|
||||
result.IsValid.Should().BeTrue();
|
||||
result.Description.Should().Be("Admin key");
|
||||
client.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.LmxProxy.Client.Domain;
|
||||
using ZB.MOM.WW.LmxProxy.Client.Tests.Fakes;
|
||||
|
||||
namespace ZB.MOM.WW.LmxProxy.Client.Tests;
|
||||
|
||||
public class LmxProxyClientSubscriptionTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task SubscribeAsync_InvokesCallbackForEachUpdate()
|
||||
{
|
||||
var (client, fake) = TestableClient.CreateConnected();
|
||||
fake.SubscriptionMessages =
|
||||
[
|
||||
new VtqMessage
|
||||
{
|
||||
Tag = "Tag1",
|
||||
Value = new TypedValue { DoubleValue = 1.0 },
|
||||
TimestampUtcTicks = DateTime.UtcNow.Ticks,
|
||||
Quality = new QualityCode { StatusCode = 0x00000000 }
|
||||
},
|
||||
new VtqMessage
|
||||
{
|
||||
Tag = "Tag2",
|
||||
Value = new TypedValue { Int32Value = 42 },
|
||||
TimestampUtcTicks = DateTime.UtcNow.Ticks,
|
||||
Quality = new QualityCode { StatusCode = 0x00000000 }
|
||||
}
|
||||
];
|
||||
|
||||
var updates = new List<(string Tag, Vtq Vtq)>();
|
||||
var subscription = await client.SubscribeAsync(
|
||||
["Tag1", "Tag2"],
|
||||
(tag, vtq) => updates.Add((tag, vtq)));
|
||||
|
||||
// Wait for processing to complete (fake yields all then stops)
|
||||
await Task.Delay(500);
|
||||
|
||||
updates.Should().HaveCount(2);
|
||||
updates[0].Tag.Should().Be("Tag1");
|
||||
updates[0].Vtq.Value.Should().Be(1.0);
|
||||
updates[1].Tag.Should().Be("Tag2");
|
||||
updates[1].Vtq.Value.Should().Be(42);
|
||||
|
||||
subscription.Dispose();
|
||||
client.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SubscribeAsync_InvokesStreamErrorOnFailure()
|
||||
{
|
||||
var (client, fake) = TestableClient.CreateConnected();
|
||||
fake.SubscriptionException = new InvalidOperationException("Stream broke");
|
||||
|
||||
Exception? capturedError = null;
|
||||
var subscription = await client.SubscribeAsync(
|
||||
["Tag1"],
|
||||
(_, _) => { },
|
||||
ex => capturedError = ex);
|
||||
|
||||
// Wait for error to propagate
|
||||
await Task.Delay(500);
|
||||
|
||||
capturedError.Should().NotBeNull();
|
||||
capturedError.Should().BeOfType<InvalidOperationException>();
|
||||
capturedError!.Message.Should().Be("Stream broke");
|
||||
|
||||
subscription.Dispose();
|
||||
client.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SubscribeAsync_DisposeStopsProcessing()
|
||||
{
|
||||
var (client, fake) = TestableClient.CreateConnected();
|
||||
// Provide many messages but we'll dispose early
|
||||
fake.SubscriptionMessages =
|
||||
[
|
||||
new VtqMessage
|
||||
{
|
||||
Tag = "Tag1",
|
||||
Value = new TypedValue { DoubleValue = 1.0 },
|
||||
TimestampUtcTicks = DateTime.UtcNow.Ticks,
|
||||
Quality = new QualityCode { StatusCode = 0x00000000 }
|
||||
}
|
||||
];
|
||||
|
||||
var updates = new List<(string Tag, Vtq Vtq)>();
|
||||
var subscription = await client.SubscribeAsync(
|
||||
["Tag1"],
|
||||
(tag, vtq) => updates.Add((tag, vtq)));
|
||||
|
||||
// Dispose immediately
|
||||
subscription.Dispose();
|
||||
|
||||
// Should not throw
|
||||
client.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.LmxProxy.Client.Domain;
|
||||
|
||||
namespace ZB.MOM.WW.LmxProxy.Client.Tests;
|
||||
|
||||
public class TypedValueConversionTests
|
||||
{
|
||||
[Fact]
|
||||
public void ConvertVtqMessage_ExtractsBoolValue()
|
||||
{
|
||||
var msg = CreateVtqMessage(new TypedValue { BoolValue = true });
|
||||
|
||||
var vtq = LmxProxyClient.ConvertVtqMessage(msg);
|
||||
|
||||
vtq.Value.Should().Be(true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertVtqMessage_ExtractsInt32Value()
|
||||
{
|
||||
var msg = CreateVtqMessage(new TypedValue { Int32Value = 42 });
|
||||
|
||||
var vtq = LmxProxyClient.ConvertVtqMessage(msg);
|
||||
|
||||
vtq.Value.Should().Be(42);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertVtqMessage_ExtractsInt64Value()
|
||||
{
|
||||
var msg = CreateVtqMessage(new TypedValue { Int64Value = long.MaxValue });
|
||||
|
||||
var vtq = LmxProxyClient.ConvertVtqMessage(msg);
|
||||
|
||||
vtq.Value.Should().Be(long.MaxValue);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertVtqMessage_ExtractsFloatValue()
|
||||
{
|
||||
var msg = CreateVtqMessage(new TypedValue { FloatValue = 3.14f });
|
||||
|
||||
var vtq = LmxProxyClient.ConvertVtqMessage(msg);
|
||||
|
||||
vtq.Value.Should().Be(3.14f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertVtqMessage_ExtractsDoubleValue()
|
||||
{
|
||||
var msg = CreateVtqMessage(new TypedValue { DoubleValue = 99.99 });
|
||||
|
||||
var vtq = LmxProxyClient.ConvertVtqMessage(msg);
|
||||
|
||||
vtq.Value.Should().Be(99.99);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertVtqMessage_ExtractsStringValue()
|
||||
{
|
||||
var msg = CreateVtqMessage(new TypedValue { StringValue = "hello" });
|
||||
|
||||
var vtq = LmxProxyClient.ConvertVtqMessage(msg);
|
||||
|
||||
vtq.Value.Should().Be("hello");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertVtqMessage_ExtractsDateTimeValue()
|
||||
{
|
||||
var dt = new DateTime(2026, 3, 22, 12, 0, 0, DateTimeKind.Utc);
|
||||
var msg = CreateVtqMessage(new TypedValue { DatetimeValue = dt.Ticks });
|
||||
|
||||
var vtq = LmxProxyClient.ConvertVtqMessage(msg);
|
||||
|
||||
vtq.Value.Should().BeOfType<DateTime>();
|
||||
((DateTime)vtq.Value!).Should().Be(dt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertVtqMessage_HandlesNullTypedValue()
|
||||
{
|
||||
var msg = new VtqMessage
|
||||
{
|
||||
Tag = "NullTag",
|
||||
Value = null,
|
||||
TimestampUtcTicks = DateTime.UtcNow.Ticks,
|
||||
Quality = new QualityCode { StatusCode = 0x00000000 }
|
||||
};
|
||||
|
||||
var vtq = LmxProxyClient.ConvertVtqMessage(msg);
|
||||
|
||||
vtq.Value.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertVtqMessage_HandlesNullMessage()
|
||||
{
|
||||
var vtq = LmxProxyClient.ConvertVtqMessage(null);
|
||||
|
||||
vtq.Value.Should().BeNull();
|
||||
vtq.Quality.Should().Be(Quality.Bad);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertVtqMessage_GoodQualityCode()
|
||||
{
|
||||
var msg = CreateVtqMessage(new TypedValue { DoubleValue = 1.0 }, statusCode: 0x00000000);
|
||||
|
||||
var vtq = LmxProxyClient.ConvertVtqMessage(msg);
|
||||
|
||||
vtq.Quality.Should().Be(Quality.Good);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertVtqMessage_BadQualityCode()
|
||||
{
|
||||
var msg = CreateVtqMessage(new TypedValue { DoubleValue = 1.0 }, statusCode: 0x80000000);
|
||||
|
||||
var vtq = LmxProxyClient.ConvertVtqMessage(msg);
|
||||
|
||||
vtq.Quality.Should().Be(Quality.Bad);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertVtqMessage_UncertainQualityCode()
|
||||
{
|
||||
var msg = CreateVtqMessage(new TypedValue { DoubleValue = 1.0 }, statusCode: 0x40000000);
|
||||
|
||||
var vtq = LmxProxyClient.ConvertVtqMessage(msg);
|
||||
|
||||
vtq.Quality.Should().Be(Quality.Uncertain);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertVtqMessage_MapsQualityCodeCorrectly()
|
||||
{
|
||||
// Test that a specific non-zero Good code still maps to Good
|
||||
var msg = CreateVtqMessage(new TypedValue { Int32Value = 5 }, statusCode: 0x00D80000);
|
||||
|
||||
var vtq = LmxProxyClient.ConvertVtqMessage(msg);
|
||||
|
||||
vtq.Quality.Should().Be(Quality.Good);
|
||||
}
|
||||
|
||||
private static VtqMessage CreateVtqMessage(TypedValue value, uint statusCode = 0x00000000)
|
||||
{
|
||||
return new VtqMessage
|
||||
{
|
||||
Tag = "TestTag",
|
||||
Value = value,
|
||||
TimestampUtcTicks = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc).Ticks,
|
||||
Quality = new QualityCode { StatusCode = statusCode }
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user