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:
Joseph Doherty
2026-03-22 00:22:29 -04:00
parent 9eb81180c0
commit 8ba75b50e8
19 changed files with 1819 additions and 0 deletions

View File

@@ -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;
}
}

View File

@@ -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);
}
}