# LmxFakeProxy Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.
**Goal:** Build a .NET 10 gRPC server (`infra/lmxfakeproxy/`) that implements the `scada.ScadaService` proto and bridges to the OPC UA test server, enabling end-to-end testing of `RealLmxProxyClient`.
**Architecture:** A standalone ASP.NET Core gRPC server with three core components: `SessionManager` (ConcurrentDictionary-based session tracking), `OpcUaBridge` (shared OPC UA session with reconnection), and `ScadaServiceImpl` (gRPC service mapping proto RPCs to the bridge). The server runs on port 50051, maps LMX-style tag addresses to OPC UA NodeIds via a configurable prefix, and optionally enforces API key auth via a gRPC interceptor.
**Tech Stack:** .NET 10, Grpc.AspNetCore, OPCFoundation.NetStandard.Opc.Ua.Client, xunit, NSubstitute
**Design doc:** `docs/plans/2026-03-19-lmxfakeproxy-design.md`
---
### Task 1: Project Scaffolding
**Files:**
- Create: `infra/lmxfakeproxy/LmxFakeProxy.csproj`
- Create: `infra/lmxfakeproxy/Program.cs` (minimal, just enough to build)
- Create: `infra/lmxfakeproxy/Protos/scada.proto` (copy from DCL)
- Create: `infra/lmxfakeproxy/tests/LmxFakeProxy.Tests/LmxFakeProxy.Tests.csproj`
**Step 1: Create the project directory structure**
```bash
mkdir -p infra/lmxfakeproxy/Services infra/lmxfakeproxy/Bridge infra/lmxfakeproxy/Sessions infra/lmxfakeproxy/Protos
mkdir -p infra/lmxfakeproxy/tests/LmxFakeProxy.Tests
```
**Step 2: Create LmxFakeProxy.csproj**
```xml
net10.0
LmxFakeProxy
enable
enable
```
**Step 3: Copy scada.proto and change the namespace**
Copy `src/ScadaLink.DataConnectionLayer/Adapters/Protos/scada.proto` to `infra/lmxfakeproxy/Protos/scada.proto`. Change the `csharp_namespace` option to:
```protobuf
option csharp_namespace = "LmxFakeProxy.Grpc";
```
Everything else in the proto stays the same — same package, same service, same messages.
**Step 4: Create minimal Program.cs**
```csharp
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddGrpc();
var app = builder.Build();
app.MapGet("/", () => "LmxFakeProxy is running");
app.Run();
```
**Step 5: Create test project csproj**
```xml
net10.0
LmxFakeProxy.Tests
enable
enable
false
```
**Step 6: Verify build**
```bash
cd infra/lmxfakeproxy && dotnet build
cd infra/lmxfakeproxy && dotnet build tests/LmxFakeProxy.Tests/
```
Expected: Both build with 0 errors. The proto generates server-side stubs in `LmxFakeProxy.Grpc` namespace.
**Step 7: Commit**
```bash
git add infra/lmxfakeproxy/
git commit -m "feat(infra): scaffold LmxFakeProxy project with proto and test project"
```
---
### Task 2: TagMapper Utility + Tests
**Files:**
- Create: `infra/lmxfakeproxy/TagMapper.cs`
- Create: `infra/lmxfakeproxy/tests/LmxFakeProxy.Tests/TagMappingTests.cs`
**Step 1: Write the failing tests**
Create `tests/LmxFakeProxy.Tests/TagMappingTests.cs`:
```csharp
namespace LmxFakeProxy.Tests;
public class TagMappingTests
{
[Fact]
public void ToOpcNodeId_PrependsPrefix()
{
var mapper = new TagMapper("ns=3;s=");
Assert.Equal("ns=3;s=Motor.Speed", mapper.ToOpcNodeId("Motor.Speed"));
}
[Fact]
public void ToOpcNodeId_CustomPrefix()
{
var mapper = new TagMapper("ns=2;s=MyFolder.");
Assert.Equal("ns=2;s=MyFolder.Pump.Pressure", mapper.ToOpcNodeId("Pump.Pressure"));
}
[Fact]
public void ToOpcNodeId_EmptyPrefix_PassesThrough()
{
var mapper = new TagMapper("");
Assert.Equal("Motor.Speed", mapper.ToOpcNodeId("Motor.Speed"));
}
[Fact]
public void ParseWriteValue_Double()
{
Assert.Equal(42.5, TagMapper.ParseWriteValue("42.5"));
Assert.IsType(TagMapper.ParseWriteValue("42.5"));
}
[Fact]
public void ParseWriteValue_Bool()
{
Assert.Equal(true, TagMapper.ParseWriteValue("true"));
Assert.Equal(false, TagMapper.ParseWriteValue("False"));
}
[Fact]
public void ParseWriteValue_Uint()
{
// "100" parses as double first (double.TryParse succeeds for integers)
// So uint only hits for values that look like uint but not double — not realistic.
// Actually, double.TryParse("100") succeeds, so this returns 100.0 (double).
// That's fine — OPC UA accepts double writes to UInt32 nodes.
var result = TagMapper.ParseWriteValue("100");
Assert.IsType(result);
}
[Fact]
public void ParseWriteValue_FallsBackToString()
{
Assert.Equal("hello", TagMapper.ParseWriteValue("hello"));
Assert.IsType(TagMapper.ParseWriteValue("hello"));
}
[Fact]
public void MapStatusCode_Good()
{
Assert.Equal("Good", TagMapper.MapQuality(0));
}
[Fact]
public void MapStatusCode_Bad()
{
Assert.Equal("Bad", TagMapper.MapQuality(0x80000000));
}
[Fact]
public void MapStatusCode_Uncertain()
{
Assert.Equal("Uncertain", TagMapper.MapQuality(0x40000000));
}
[Fact]
public void ToVtqMessage_ConvertsCorrectly()
{
var vtq = TagMapper.ToVtqMessage("Motor.Speed", 42.5, DateTime.UtcNow, 0);
Assert.Equal("Motor.Speed", vtq.Tag);
Assert.Equal("42.5", vtq.Value);
Assert.Equal("Good", vtq.Quality);
Assert.True(vtq.TimestampUtcTicks > 0);
}
}
```
**Step 2: Run tests to verify they fail**
```bash
cd infra/lmxfakeproxy && dotnet test tests/LmxFakeProxy.Tests/ -v n
```
Expected: FAIL — `TagMapper` class does not exist.
**Step 3: Implement TagMapper**
Create `infra/lmxfakeproxy/TagMapper.cs`:
```csharp
using LmxFakeProxy.Grpc;
namespace LmxFakeProxy;
public class TagMapper
{
private readonly string _prefix;
public TagMapper(string prefix)
{
_prefix = prefix;
}
public string ToOpcNodeId(string lmxTag) => $"{_prefix}{lmxTag}";
public static object ParseWriteValue(string value)
{
if (double.TryParse(value, System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture, out var d))
return d;
if (bool.TryParse(value, out var b))
return b;
return value;
}
public static string MapQuality(uint statusCode)
{
if (statusCode == 0) return "Good";
if ((statusCode & 0x80000000) != 0) return "Bad";
return "Uncertain";
}
public static VtqMessage ToVtqMessage(string tag, object? value, DateTime timestampUtc, uint statusCode)
{
return new VtqMessage
{
Tag = tag,
Value = value?.ToString() ?? string.Empty,
TimestampUtcTicks = timestampUtc.Ticks,
Quality = MapQuality(statusCode)
};
}
}
```
**Step 4: Run tests to verify they pass**
```bash
cd infra/lmxfakeproxy && dotnet test tests/LmxFakeProxy.Tests/ -v n
```
Expected: All 11 tests PASS.
**Step 5: Commit**
```bash
git add infra/lmxfakeproxy/TagMapper.cs infra/lmxfakeproxy/tests/
git commit -m "feat(infra): add TagMapper with address mapping, value parsing, and quality mapping"
```
---
### Task 3: SessionManager + Tests
**Files:**
- Create: `infra/lmxfakeproxy/Sessions/SessionManager.cs`
- Create: `infra/lmxfakeproxy/tests/LmxFakeProxy.Tests/SessionManagerTests.cs`
**Step 1: Write the failing tests**
Create `tests/LmxFakeProxy.Tests/SessionManagerTests.cs`:
```csharp
namespace LmxFakeProxy.Tests;
using LmxFakeProxy.Sessions;
public class SessionManagerTests
{
[Fact]
public void Connect_ReturnsUniqueSessionId()
{
var mgr = new SessionManager(null);
var (ok1, _, id1) = mgr.Connect("client1", "");
var (ok2, _, id2) = mgr.Connect("client2", "");
Assert.True(ok1);
Assert.True(ok2);
Assert.NotEqual(id1, id2);
}
[Fact]
public void Connect_WithValidApiKey_Succeeds()
{
var mgr = new SessionManager("secret");
var (ok, _, _) = mgr.Connect("client1", "secret");
Assert.True(ok);
}
[Fact]
public void Connect_WithInvalidApiKey_Fails()
{
var mgr = new SessionManager("secret");
var (ok, msg, id) = mgr.Connect("client1", "wrong");
Assert.False(ok);
Assert.Empty(id);
Assert.Contains("Invalid API key", msg);
}
[Fact]
public void Connect_WithNoKeyConfigured_AcceptsAnyKey()
{
var mgr = new SessionManager(null);
var (ok1, _, _) = mgr.Connect("c1", "anykey");
var (ok2, _, _) = mgr.Connect("c2", "");
Assert.True(ok1);
Assert.True(ok2);
}
[Fact]
public void Disconnect_RemovesSession()
{
var mgr = new SessionManager(null);
var (_, _, id) = mgr.Connect("client1", "");
Assert.True(mgr.ValidateSession(id));
var ok = mgr.Disconnect(id);
Assert.True(ok);
Assert.False(mgr.ValidateSession(id));
}
[Fact]
public void Disconnect_UnknownSession_ReturnsFalse()
{
var mgr = new SessionManager(null);
Assert.False(mgr.Disconnect("nonexistent"));
}
[Fact]
public void ValidateSession_ValidId_ReturnsTrue()
{
var mgr = new SessionManager(null);
var (_, _, id) = mgr.Connect("client1", "");
Assert.True(mgr.ValidateSession(id));
}
[Fact]
public void ValidateSession_InvalidId_ReturnsFalse()
{
var mgr = new SessionManager(null);
Assert.False(mgr.ValidateSession("bogus"));
}
[Fact]
public void GetConnectionState_ReturnsCorrectInfo()
{
var mgr = new SessionManager(null);
var (_, _, id) = mgr.Connect("myClient", "");
var (found, clientId, ticks) = mgr.GetConnectionState(id);
Assert.True(found);
Assert.Equal("myClient", clientId);
Assert.True(ticks > 0);
}
[Fact]
public void GetConnectionState_UnknownSession_ReturnsNotConnected()
{
var mgr = new SessionManager(null);
var (found, clientId, ticks) = mgr.GetConnectionState("unknown");
Assert.False(found);
Assert.Empty(clientId);
Assert.Equal(0, ticks);
}
[Fact]
public void CheckApiKey_NoKeyConfigured_AlwaysValid()
{
var mgr = new SessionManager(null);
Assert.True(mgr.CheckApiKey("anything"));
Assert.True(mgr.CheckApiKey(""));
}
[Fact]
public void CheckApiKey_WithKeyConfigured_ValidatesCorrectly()
{
var mgr = new SessionManager("mykey");
Assert.True(mgr.CheckApiKey("mykey"));
Assert.False(mgr.CheckApiKey("wrong"));
Assert.False(mgr.CheckApiKey(""));
}
}
```
**Step 2: Run tests to verify they fail**
```bash
cd infra/lmxfakeproxy && dotnet test tests/LmxFakeProxy.Tests/ -v n
```
Expected: FAIL — `SessionManager` does not exist.
**Step 3: Implement SessionManager**
Create `infra/lmxfakeproxy/Sessions/SessionManager.cs`:
```csharp
using System.Collections.Concurrent;
namespace LmxFakeProxy.Sessions;
public record SessionInfo(string ClientId, long ConnectedSinceUtcTicks);
public class SessionManager
{
private readonly string? _requiredApiKey;
private readonly ConcurrentDictionary _sessions = new();
public SessionManager(string? requiredApiKey)
{
_requiredApiKey = requiredApiKey;
}
public (bool Success, string Message, string SessionId) Connect(string clientId, string apiKey)
{
if (!CheckApiKey(apiKey))
return (false, "Invalid API key", string.Empty);
var sessionId = Guid.NewGuid().ToString("N");
var info = new SessionInfo(clientId, DateTime.UtcNow.Ticks);
_sessions[sessionId] = info;
return (true, "Connected", sessionId);
}
public bool Disconnect(string sessionId)
{
return _sessions.TryRemove(sessionId, out _);
}
public bool ValidateSession(string sessionId)
{
return _sessions.ContainsKey(sessionId);
}
public (bool Found, string ClientId, long ConnectedSinceUtcTicks) GetConnectionState(string sessionId)
{
if (_sessions.TryGetValue(sessionId, out var info))
return (true, info.ClientId, info.ConnectedSinceUtcTicks);
return (false, string.Empty, 0);
}
public bool CheckApiKey(string apiKey)
{
if (string.IsNullOrEmpty(_requiredApiKey))
return true;
return apiKey == _requiredApiKey;
}
}
```
**Step 4: Run tests to verify they pass**
```bash
cd infra/lmxfakeproxy && dotnet test tests/LmxFakeProxy.Tests/ -v n
```
Expected: All 23 tests PASS (11 TagMapping + 12 SessionManager).
**Step 5: Commit**
```bash
git add infra/lmxfakeproxy/Sessions/ infra/lmxfakeproxy/tests/
git commit -m "feat(infra): add SessionManager with full session tracking and API key validation"
```
---
### Task 4: IOpcUaBridge Interface + OpcUaBridge Implementation
**Files:**
- Create: `infra/lmxfakeproxy/Bridge/IOpcUaBridge.cs`
- Create: `infra/lmxfakeproxy/Bridge/OpcUaBridge.cs`
**Step 1: Create the IOpcUaBridge interface**
Create `infra/lmxfakeproxy/Bridge/IOpcUaBridge.cs`:
```csharp
namespace LmxFakeProxy.Bridge;
public record OpcUaReadResult(object? Value, DateTime SourceTimestamp, uint StatusCode);
public interface IOpcUaBridge : IAsyncDisposable
{
bool IsConnected { get; }
Task ConnectAsync(CancellationToken cancellationToken = default);
Task ReadAsync(string nodeId, CancellationToken cancellationToken = default);
Task WriteAsync(string nodeId, object? value, CancellationToken cancellationToken = default);
///
/// Add monitored items to the shared OPC UA subscription.
/// Returns a handle that can be used to remove them later.
/// The callback receives (nodeId, value, timestamp, statusCode) on each data change.
///
Task AddMonitoredItemsAsync(
IEnumerable nodeIds,
int samplingIntervalMs,
Action onValueChanged,
CancellationToken cancellationToken = default);
Task RemoveMonitoredItemsAsync(string handle, CancellationToken cancellationToken = default);
///
/// Raised when the OPC UA backend becomes unreachable.
///
event Action? Disconnected;
///
/// Raised when the OPC UA backend reconnects after a disconnection.
///
event Action? Reconnected;
}
```
**Step 2: Implement OpcUaBridge**
Create `infra/lmxfakeproxy/Bridge/OpcUaBridge.cs`. This mirrors `RealOpcUaClient` from the main project but adds reconnection logic and multi-client monitored item management:
```csharp
using Opc.Ua;
using Opc.Ua.Client;
using Opc.Ua.Configuration;
namespace LmxFakeProxy.Bridge;
public class OpcUaBridge : IOpcUaBridge
{
private readonly string _endpointUrl;
private readonly ILogger _logger;
private ISession? _session;
private Subscription? _subscription;
private volatile bool _connected;
private volatile bool _reconnecting;
private CancellationTokenSource? _reconnectCts;
// Track monitored items per handle (one handle per Subscribe call)
private readonly Dictionary> _handleItems = new();
private readonly Dictionary> _handleCallbacks = new();
private readonly object _lock = new();
public OpcUaBridge(string endpointUrl, ILogger logger)
{
_endpointUrl = endpointUrl;
_logger = logger;
}
public bool IsConnected => _connected;
public event Action? Disconnected;
public event Action? Reconnected;
public async Task ConnectAsync(CancellationToken cancellationToken = default)
{
var appConfig = new ApplicationConfiguration
{
ApplicationName = "LmxFakeProxy",
ApplicationType = ApplicationType.Client,
SecurityConfiguration = new SecurityConfiguration
{
AutoAcceptUntrustedCertificates = true,
ApplicationCertificate = new CertificateIdentifier(),
TrustedIssuerCertificates = new CertificateTrustList { StorePath = Path.Combine(Path.GetTempPath(), "LmxFakeProxy", "pki", "issuers") },
TrustedPeerCertificates = new CertificateTrustList { StorePath = Path.Combine(Path.GetTempPath(), "LmxFakeProxy", "pki", "trusted") },
RejectedCertificateStore = new CertificateTrustList { StorePath = Path.Combine(Path.GetTempPath(), "LmxFakeProxy", "pki", "rejected") }
},
ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60000 },
TransportQuotas = new TransportQuotas { OperationTimeout = 15000 }
};
await appConfig.ValidateAsync(ApplicationType.Client);
appConfig.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true;
EndpointDescription? endpoint;
try
{
#pragma warning disable CS0618
using var discoveryClient = DiscoveryClient.Create(new Uri(_endpointUrl));
var endpoints = discoveryClient.GetEndpoints(null);
#pragma warning restore CS0618
endpoint = endpoints
.Where(e => e.SecurityMode == MessageSecurityMode.None)
.FirstOrDefault() ?? endpoints.FirstOrDefault();
}
catch
{
endpoint = new EndpointDescription(_endpointUrl);
}
var endpointConfig = EndpointConfiguration.Create(appConfig);
var configuredEndpoint = new ConfiguredEndpoint(null, endpoint, endpointConfig);
#pragma warning disable CS0618
var sessionFactory = new DefaultSessionFactory();
#pragma warning restore CS0618
_session = await sessionFactory.CreateAsync(
appConfig, configuredEndpoint, false,
"LmxFakeProxy-Session", 60000, null, null, cancellationToken);
_session.KeepAlive += OnSessionKeepAlive;
_subscription = new Subscription(_session.DefaultSubscription)
{
DisplayName = "LmxFakeProxy",
PublishingEnabled = true,
PublishingInterval = 500,
KeepAliveCount = 10,
LifetimeCount = 30,
MaxNotificationsPerPublish = 1000
};
_session.AddSubscription(_subscription);
await _subscription.CreateAsync(cancellationToken);
_connected = true;
_logger.LogInformation("OPC UA bridge connected to {Endpoint}", _endpointUrl);
}
public async Task ReadAsync(string nodeId, CancellationToken cancellationToken = default)
{
EnsureConnected();
var readValue = new ReadValueId { NodeId = nodeId, AttributeId = Attributes.Value };
var response = await _session!.ReadAsync(
null, 0, TimestampsToReturn.Source,
new ReadValueIdCollection { readValue }, cancellationToken);
var result = response.Results[0];
return new OpcUaReadResult(result.Value, result.SourceTimestamp, result.StatusCode.Code);
}
public async Task WriteAsync(string nodeId, object? value, CancellationToken cancellationToken = default)
{
EnsureConnected();
var writeValue = new WriteValue
{
NodeId = nodeId,
AttributeId = Attributes.Value,
Value = new DataValue(new Variant(value))
};
var response = await _session!.WriteAsync(
null, new WriteValueCollection { writeValue }, cancellationToken);
return response.Results[0].Code;
}
public async Task AddMonitoredItemsAsync(
IEnumerable nodeIds,
int samplingIntervalMs,
Action onValueChanged,
CancellationToken cancellationToken = default)
{
EnsureConnected();
var handle = Guid.NewGuid().ToString("N");
var items = new List();
foreach (var nodeId in nodeIds)
{
var monitoredItem = new MonitoredItem(_subscription!.DefaultItem)
{
DisplayName = nodeId,
StartNodeId = nodeId,
AttributeId = Attributes.Value,
SamplingInterval = samplingIntervalMs,
QueueSize = 10,
DiscardOldest = true
};
monitoredItem.Notification += (item, e) =>
{
if (e.NotificationValue is MonitoredItemNotification notification)
{
var val = notification.Value?.Value;
var ts = notification.Value?.SourceTimestamp ?? DateTime.UtcNow;
var sc = notification.Value?.StatusCode.Code ?? 0;
onValueChanged(nodeId, val, ts, sc);
}
};
items.Add(monitoredItem);
_subscription!.AddItem(monitoredItem);
}
await _subscription!.ApplyChangesAsync(cancellationToken);
lock (_lock)
{
_handleItems[handle] = items;
_handleCallbacks[handle] = onValueChanged;
}
return handle;
}
public async Task RemoveMonitoredItemsAsync(string handle, CancellationToken cancellationToken = default)
{
List? items;
lock (_lock)
{
if (!_handleItems.Remove(handle, out items))
return;
_handleCallbacks.Remove(handle);
}
if (_subscription != null)
{
foreach (var item in items)
_subscription.RemoveItem(item);
try { await _subscription.ApplyChangesAsync(cancellationToken); }
catch { /* best-effort during cleanup */ }
}
}
private void OnSessionKeepAlive(ISession session, KeepAliveEventArgs e)
{
if (ServiceResult.IsBad(e.Status))
{
if (!_connected) return;
_connected = false;
_logger.LogWarning("OPC UA backend connection lost");
Disconnected?.Invoke();
StartReconnectLoop();
}
}
private void StartReconnectLoop()
{
if (_reconnecting) return;
_reconnecting = true;
_reconnectCts = new CancellationTokenSource();
_ = Task.Run(async () =>
{
while (!_reconnectCts.Token.IsCancellationRequested)
{
await Task.Delay(5000, _reconnectCts.Token);
try
{
_logger.LogInformation("Attempting OPC UA reconnection...");
// Clean up old session
if (_session != null)
{
_session.KeepAlive -= OnSessionKeepAlive;
try { await _session.CloseAsync(); } catch { }
_session = null;
_subscription = null;
}
await ConnectAsync(_reconnectCts.Token);
// Re-add monitored items for active handles
lock (_lock)
{
foreach (var (handle, callback) in _handleCallbacks)
{
if (_handleItems.TryGetValue(handle, out var oldItems))
{
var nodeIds = oldItems.Select(i => i.StartNodeId.ToString()).ToList();
var newItems = new List();
foreach (var nodeId in nodeIds)
{
var monitoredItem = new MonitoredItem(_subscription!.DefaultItem)
{
DisplayName = nodeId,
StartNodeId = nodeId,
AttributeId = Attributes.Value,
SamplingInterval = oldItems[0].SamplingInterval,
QueueSize = 10,
DiscardOldest = true
};
var capturedNodeId = nodeId;
var capturedCallback = callback;
monitoredItem.Notification += (item, ev) =>
{
if (ev.NotificationValue is MonitoredItemNotification notification)
{
var val = notification.Value?.Value;
var ts = notification.Value?.SourceTimestamp ?? DateTime.UtcNow;
var sc = notification.Value?.StatusCode.Code ?? 0;
capturedCallback(capturedNodeId, val, ts, sc);
}
};
newItems.Add(monitoredItem);
_subscription!.AddItem(monitoredItem);
}
_handleItems[handle] = newItems;
}
}
}
if (_subscription != null)
await _subscription.ApplyChangesAsync();
_reconnecting = false;
_logger.LogInformation("OPC UA reconnection successful");
Reconnected?.Invoke();
return;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "OPC UA reconnection attempt failed, retrying in 5s");
}
}
}, _reconnectCts.Token);
}
private void EnsureConnected()
{
if (!_connected || _session == null)
throw new InvalidOperationException("OPC UA backend unavailable");
}
public async ValueTask DisposeAsync()
{
_reconnectCts?.Cancel();
_reconnectCts?.Dispose();
if (_subscription != null)
{
try { await _subscription.DeleteAsync(true); } catch { }
_subscription = null;
}
if (_session != null)
{
_session.KeepAlive -= OnSessionKeepAlive;
try { await _session.CloseAsync(); } catch { }
_session = null;
}
_connected = false;
}
}
```
**Step 3: Verify build**
```bash
cd infra/lmxfakeproxy && dotnet build
```
Expected: 0 errors.
**Step 4: Commit**
```bash
git add infra/lmxfakeproxy/Bridge/
git commit -m "feat(infra): add IOpcUaBridge interface and OpcUaBridge with OPC UA reconnection"
```
---
### Task 5: ScadaServiceImpl (gRPC Service) + Tests
**Files:**
- Create: `infra/lmxfakeproxy/Services/ScadaServiceImpl.cs`
- Create: `infra/lmxfakeproxy/tests/LmxFakeProxy.Tests/ScadaServiceTests.cs`
**Step 1: Write the failing tests**
Create `tests/LmxFakeProxy.Tests/ScadaServiceTests.cs`. These tests mock `IOpcUaBridge` and use real `SessionManager` + `TagMapper`:
```csharp
using Grpc.Core;
using NSubstitute;
using LmxFakeProxy.Bridge;
using LmxFakeProxy.Grpc;
using LmxFakeProxy.Sessions;
using LmxFakeProxy.Services;
namespace LmxFakeProxy.Tests;
public class ScadaServiceTests
{
private readonly IOpcUaBridge _mockBridge;
private readonly SessionManager _sessionMgr;
private readonly TagMapper _tagMapper;
private readonly ScadaServiceImpl _service;
public ScadaServiceTests()
{
_mockBridge = Substitute.For();
_mockBridge.IsConnected.Returns(true);
_sessionMgr = new SessionManager(null);
_tagMapper = new TagMapper("ns=3;s=");
_service = new ScadaServiceImpl(_sessionMgr, _mockBridge, _tagMapper);
}
private string ConnectClient(string clientId = "test-client")
{
var (_, _, sessionId) = _sessionMgr.Connect(clientId, "");
return sessionId;
}
private static ServerCallContext MockContext()
{
return new TestServerCallContext();
}
// --- Connection ---
[Fact]
public async Task Connect_ReturnsSessionId()
{
var resp = await _service.Connect(
new ConnectRequest { ClientId = "c1", ApiKey = "" }, MockContext());
Assert.True(resp.Success);
Assert.NotEmpty(resp.SessionId);
}
// --- Read ---
[Fact]
public async Task Read_ValidSession_ReturnsVtq()
{
var sid = ConnectClient();
_mockBridge.ReadAsync("ns=3;s=Motor.Speed", Arg.Any())
.Returns(new OpcUaReadResult(42.5, DateTime.UtcNow, 0));
var resp = await _service.Read(
new ReadRequest { SessionId = sid, Tag = "Motor.Speed" }, MockContext());
Assert.True(resp.Success);
Assert.Equal("42.5", resp.Vtq.Value);
Assert.Equal("Good", resp.Vtq.Quality);
}
[Fact]
public async Task Read_InvalidSession_ReturnsFailure()
{
var resp = await _service.Read(
new ReadRequest { SessionId = "bogus", Tag = "Motor.Speed" }, MockContext());
Assert.False(resp.Success);
Assert.Contains("Invalid", resp.Message);
}
[Fact]
public async Task ReadBatch_ReturnsAllTags()
{
var sid = ConnectClient();
_mockBridge.ReadAsync(Arg.Any(), Arg.Any())
.Returns(new OpcUaReadResult(1.0, DateTime.UtcNow, 0));
var req = new ReadBatchRequest { SessionId = sid };
req.Tags.AddRange(["Motor.Speed", "Pump.FlowRate"]);
var resp = await _service.ReadBatch(req, MockContext());
Assert.True(resp.Success);
Assert.Equal(2, resp.Vtqs.Count);
}
// --- Write ---
[Fact]
public async Task Write_ValidSession_Succeeds()
{
var sid = ConnectClient();
_mockBridge.WriteAsync("ns=3;s=Motor.Speed", Arg.Any