deprecate(lmxproxy): move all LmxProxy code, tests, and docs to deprecated/

LmxProxy is no longer needed. Moved the entire lmxproxy/ workspace, DCL
adapter files, and related docs to deprecated/. Removed LmxProxy registration
from DataConnectionFactory, project reference from DCL, protocol option from
UI, and cleaned up all requirement docs.
This commit is contained in:
Joseph Doherty
2026-04-08 15:56:23 -04:00
parent 8423915ba1
commit 9dccf8e72f
220 changed files with 25 additions and 132 deletions

View File

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

View File

@@ -0,0 +1,270 @@
using System.IO;
using FluentAssertions;
using Google.Protobuf;
using ProtoBuf;
using Xunit;
using ProtoGenerated = Scada;
using CodeFirst = ZB.MOM.WW.LmxProxy.Client.Domain;
namespace ZB.MOM.WW.LmxProxy.Client.Tests.CrossStack;
/// <summary>
/// Verifies wire compatibility between Host proto-generated types and Client code-first types.
/// Serializes with one stack, deserializes with the other.
/// </summary>
public class CrossStackSerializationTests
{
// ── Proto-generated → Code-first ──────────────────────────
[Fact]
public void VtqMessage_ProtoToCodeFirst_BoolValue()
{
// Arrange: proto-generated VtqMessage with bool TypedValue
var protoMsg = new ProtoGenerated.VtqMessage
{
Tag = "Motor.Running",
Value = new ProtoGenerated.TypedValue { BoolValue = true },
TimestampUtcTicks = 638789000000000000L,
Quality = new ProtoGenerated.QualityCode { StatusCode = 0x00000000, SymbolicName = "Good" }
};
// Act: serialize with proto, deserialize with protobuf-net
var bytes = protoMsg.ToByteArray();
var codeFirst = Serializer.Deserialize<CodeFirst.VtqMessage>(new MemoryStream(bytes));
// Assert
codeFirst.Should().NotBeNull();
codeFirst.Tag.Should().Be("Motor.Running");
codeFirst.Value.Should().NotBeNull();
codeFirst.Value!.BoolValue.Should().BeTrue();
codeFirst.TimestampUtcTicks.Should().Be(638789000000000000L);
codeFirst.Quality.Should().NotBeNull();
codeFirst.Quality!.StatusCode.Should().Be(0x00000000u);
codeFirst.Quality.SymbolicName.Should().Be("Good");
}
[Fact]
public void VtqMessage_ProtoToCodeFirst_DoubleValue()
{
var protoMsg = new ProtoGenerated.VtqMessage
{
Tag = "Motor.Speed",
Value = new ProtoGenerated.TypedValue { DoubleValue = 42.5 },
TimestampUtcTicks = 638789000000000000L,
Quality = new ProtoGenerated.QualityCode { StatusCode = 0x00000000, SymbolicName = "Good" }
};
var bytes = protoMsg.ToByteArray();
var codeFirst = Serializer.Deserialize<CodeFirst.VtqMessage>(new MemoryStream(bytes));
codeFirst.Value.Should().NotBeNull();
codeFirst.Value!.DoubleValue.Should().Be(42.5);
}
[Fact]
public void VtqMessage_ProtoToCodeFirst_StringValue()
{
var protoMsg = new ProtoGenerated.VtqMessage
{
Tag = "Motor.Name",
Value = new ProtoGenerated.TypedValue { StringValue = "Pump A" },
TimestampUtcTicks = 638789000000000000L,
Quality = new ProtoGenerated.QualityCode { StatusCode = 0x00000000, SymbolicName = "Good" }
};
var bytes = protoMsg.ToByteArray();
var codeFirst = Serializer.Deserialize<CodeFirst.VtqMessage>(new MemoryStream(bytes));
codeFirst.Value.Should().NotBeNull();
codeFirst.Value!.StringValue.Should().Be("Pump A");
}
[Fact]
public void VtqMessage_ProtoToCodeFirst_Int32Value()
{
var protoMsg = new ProtoGenerated.VtqMessage
{
Tag = "Motor.Count",
Value = new ProtoGenerated.TypedValue { Int32Value = 2147483647 },
TimestampUtcTicks = 638789000000000000L,
Quality = new ProtoGenerated.QualityCode { StatusCode = 0x00000000, SymbolicName = "Good" }
};
var bytes = protoMsg.ToByteArray();
var codeFirst = Serializer.Deserialize<CodeFirst.VtqMessage>(new MemoryStream(bytes));
codeFirst.Value!.Int32Value.Should().Be(int.MaxValue);
}
[Fact]
public void VtqMessage_ProtoToCodeFirst_BadQuality()
{
var protoMsg = new ProtoGenerated.VtqMessage
{
Tag = "Motor.Fault",
TimestampUtcTicks = 638789000000000000L,
Quality = new ProtoGenerated.QualityCode { StatusCode = 0x806D0000, SymbolicName = "BadSensorFailure" }
};
var bytes = protoMsg.ToByteArray();
var codeFirst = Serializer.Deserialize<CodeFirst.VtqMessage>(new MemoryStream(bytes));
codeFirst.Quality!.StatusCode.Should().Be(0x806D0000u);
codeFirst.Quality.SymbolicName.Should().Be("BadSensorFailure");
codeFirst.Quality.IsBad.Should().BeTrue();
}
[Fact]
public void VtqMessage_ProtoToCodeFirst_NullValue()
{
// No Value field set — represents null
var protoMsg = new ProtoGenerated.VtqMessage
{
Tag = "Motor.Optional",
TimestampUtcTicks = 638789000000000000L,
Quality = new ProtoGenerated.QualityCode { StatusCode = 0x80000000, SymbolicName = "Bad" }
};
var bytes = protoMsg.ToByteArray();
var codeFirst = Serializer.Deserialize<CodeFirst.VtqMessage>(new MemoryStream(bytes));
// When no oneof is set, the Value object may be null or all-default
// Either way, GetValueCase() should return None
if (codeFirst.Value != null)
codeFirst.Value.GetValueCase().Should().Be(CodeFirst.TypedValueCase.None);
}
[Fact]
public void VtqMessage_ProtoToCodeFirst_FloatArrayValue()
{
var floatArr = new ProtoGenerated.FloatArray();
floatArr.Values.AddRange(new[] { 1.0f, 2.0f, 3.0f });
var protoMsg = new ProtoGenerated.VtqMessage
{
Tag = "Motor.Samples",
Value = new ProtoGenerated.TypedValue
{
ArrayValue = new ProtoGenerated.ArrayValue { FloatValues = floatArr }
},
TimestampUtcTicks = 638789000000000000L,
Quality = new ProtoGenerated.QualityCode { StatusCode = 0x00000000, SymbolicName = "Good" }
};
var bytes = protoMsg.ToByteArray();
var codeFirst = Serializer.Deserialize<CodeFirst.VtqMessage>(new MemoryStream(bytes));
codeFirst.Value.Should().NotBeNull();
codeFirst.Value!.ArrayValue.Should().NotBeNull();
codeFirst.Value.ArrayValue!.FloatValues.Should().NotBeNull();
codeFirst.Value.ArrayValue.FloatValues!.Values.Should().BeEquivalentTo(new[] { 1.0f, 2.0f, 3.0f });
}
// ── Code-first → Proto-generated ──────────────────────────
[Fact]
public void VtqMessage_CodeFirstToProto_DoubleValue()
{
var codeFirst = new CodeFirst.VtqMessage
{
Tag = "Motor.Speed",
Value = new CodeFirst.TypedValue { DoubleValue = 99.9 },
TimestampUtcTicks = 638789000000000000L,
Quality = new CodeFirst.QualityCode { StatusCode = 0x00000000, SymbolicName = "Good" }
};
// Serialize with protobuf-net
var ms = new MemoryStream();
Serializer.Serialize(ms, codeFirst);
var bytes = ms.ToArray();
// Deserialize with Google.Protobuf
var protoMsg = ProtoGenerated.VtqMessage.Parser.ParseFrom(bytes);
protoMsg.Tag.Should().Be("Motor.Speed");
protoMsg.Value.Should().NotBeNull();
protoMsg.Value.DoubleValue.Should().Be(99.9);
protoMsg.TimestampUtcTicks.Should().Be(638789000000000000L);
protoMsg.Quality.StatusCode.Should().Be(0x00000000u);
}
[Fact]
public void WriteRequest_CodeFirstToProto()
{
var codeFirst = new CodeFirst.WriteRequest
{
SessionId = "abc123",
Tag = "Motor.Speed",
Value = new CodeFirst.TypedValue { DoubleValue = 42.5 }
};
var ms = new MemoryStream();
Serializer.Serialize(ms, codeFirst);
var bytes = ms.ToArray();
var protoMsg = ProtoGenerated.WriteRequest.Parser.ParseFrom(bytes);
protoMsg.SessionId.Should().Be("abc123");
protoMsg.Tag.Should().Be("Motor.Speed");
protoMsg.Value.Should().NotBeNull();
protoMsg.Value.DoubleValue.Should().Be(42.5);
}
[Fact]
public void ConnectRequest_RoundTrips()
{
var codeFirst = new CodeFirst.ConnectRequest { ClientId = "ScadaLink-1", ApiKey = "key-123" };
var ms = new MemoryStream();
Serializer.Serialize(ms, codeFirst);
var protoMsg = ProtoGenerated.ConnectRequest.Parser.ParseFrom(ms.ToArray());
protoMsg.ClientId.Should().Be("ScadaLink-1");
protoMsg.ApiKey.Should().Be("key-123");
}
[Fact]
public void ConnectResponse_RoundTrips()
{
var protoMsg = new ProtoGenerated.ConnectResponse
{
Success = true,
Message = "Connected",
SessionId = "abcdef1234567890abcdef1234567890"
};
var bytes = protoMsg.ToByteArray();
var codeFirst = Serializer.Deserialize<CodeFirst.ConnectResponse>(new MemoryStream(bytes));
codeFirst.Success.Should().BeTrue();
codeFirst.Message.Should().Be("Connected");
codeFirst.SessionId.Should().Be("abcdef1234567890abcdef1234567890");
}
[Fact]
public void WriteBatchAndWaitRequest_CodeFirstToProto_TypedFlagValue()
{
var codeFirst = new CodeFirst.WriteBatchAndWaitRequest
{
SessionId = "sess1",
FlagTag = "Motor.Done",
FlagValue = new CodeFirst.TypedValue { BoolValue = true },
TimeoutMs = 5000,
PollIntervalMs = 100,
Items =
{
new CodeFirst.WriteItem
{
Tag = "Motor.Speed",
Value = new CodeFirst.TypedValue { DoubleValue = 50.0 }
}
}
};
var ms = new MemoryStream();
Serializer.Serialize(ms, codeFirst);
var protoMsg = ProtoGenerated.WriteBatchAndWaitRequest.Parser.ParseFrom(ms.ToArray());
protoMsg.FlagTag.Should().Be("Motor.Done");
protoMsg.FlagValue.BoolValue.Should().BeTrue();
protoMsg.TimeoutMs.Should().Be(5000);
protoMsg.PollIntervalMs.Should().Be(100);
protoMsg.Items.Should().HaveCount(1);
protoMsg.Items[0].Tag.Should().Be("Motor.Speed");
protoMsg.Items[0].Value.DoubleValue.Should().Be(50.0);
}
}

View File

@@ -0,0 +1,29 @@
using FluentAssertions;
using Xunit;
using ZB.MOM.WW.LmxProxy.Client.Domain;
namespace ZB.MOM.WW.LmxProxy.Client.Tests.Domain;
public class QualityExtensionsTests
{
[Theory]
[InlineData(Quality.Good, true)]
[InlineData(Quality.Good_LocalOverride, true)]
[InlineData(Quality.Uncertain, false)]
[InlineData(Quality.Bad, false)]
public void IsGood(Quality q, bool expected) => q.IsGood().Should().Be(expected);
[Theory]
[InlineData(Quality.Uncertain, true)]
[InlineData(Quality.Uncertain_LastUsable, true)]
[InlineData(Quality.Good, false)]
[InlineData(Quality.Bad, false)]
public void IsUncertain(Quality q, bool expected) => q.IsUncertain().Should().Be(expected);
[Theory]
[InlineData(Quality.Bad, true)]
[InlineData(Quality.Bad_CommFailure, true)]
[InlineData(Quality.Good, false)]
[InlineData(Quality.Uncertain, false)]
public void IsBad(Quality q, bool expected) => q.IsBad().Should().Be(expected);
}

View File

@@ -0,0 +1,134 @@
using FluentAssertions;
using Xunit;
using ZB.MOM.WW.LmxProxy.Client.Domain;
namespace ZB.MOM.WW.LmxProxy.Client.Tests.Domain;
public class ScadaContractsTests
{
[Fact]
public void TypedValue_GetValueCase_Bool()
{
var tv = new TypedValue { BoolValue = true };
tv.GetValueCase().Should().Be(TypedValueCase.BoolValue);
}
[Fact]
public void TypedValue_GetValueCase_Int32()
{
var tv = new TypedValue { Int32Value = 42 };
tv.GetValueCase().Should().Be(TypedValueCase.Int32Value);
}
[Fact]
public void TypedValue_GetValueCase_Double()
{
var tv = new TypedValue { DoubleValue = 3.14 };
tv.GetValueCase().Should().Be(TypedValueCase.DoubleValue);
}
[Fact]
public void TypedValue_GetValueCase_String()
{
var tv = new TypedValue { StringValue = "hello" };
tv.GetValueCase().Should().Be(TypedValueCase.StringValue);
}
[Fact]
public void TypedValue_GetValueCase_None_WhenDefault()
{
var tv = new TypedValue();
tv.GetValueCase().Should().Be(TypedValueCase.None);
}
[Fact]
public void TypedValue_GetValueCase_Datetime()
{
var tv = new TypedValue { DatetimeValue = DateTime.UtcNow.Ticks };
tv.GetValueCase().Should().Be(TypedValueCase.DatetimeValue);
}
[Fact]
public void TypedValue_GetValueCase_BytesValue()
{
var tv = new TypedValue { BytesValue = new byte[] { 1, 2, 3 } };
tv.GetValueCase().Should().Be(TypedValueCase.BytesValue);
}
[Fact]
public void TypedValue_GetValueCase_ArrayValue()
{
var tv = new TypedValue
{
ArrayValue = new ArrayValue
{
FloatValues = new FloatArray { Values = { 1.0f, 2.0f } }
}
};
tv.GetValueCase().Should().Be(TypedValueCase.ArrayValue);
}
[Fact]
public void QualityCode_IsGood()
{
var qc = new QualityCode { StatusCode = 0x00000000, SymbolicName = "Good" };
qc.IsGood.Should().BeTrue();
qc.IsBad.Should().BeFalse();
qc.IsUncertain.Should().BeFalse();
}
[Fact]
public void QualityCode_IsBad()
{
var qc = new QualityCode { StatusCode = 0x80000000, SymbolicName = "Bad" };
qc.IsGood.Should().BeFalse();
qc.IsBad.Should().BeTrue();
qc.IsUncertain.Should().BeFalse();
}
[Fact]
public void QualityCode_IsUncertain()
{
var qc = new QualityCode { StatusCode = 0x40900000, SymbolicName = "UncertainLastUsableValue" };
qc.IsGood.Should().BeFalse();
qc.IsBad.Should().BeFalse();
qc.IsUncertain.Should().BeTrue();
}
[Fact]
public void VtqMessage_DefaultProperties()
{
var vtq = new VtqMessage();
vtq.Tag.Should().BeEmpty();
vtq.Value.Should().BeNull();
vtq.TimestampUtcTicks.Should().Be(0);
vtq.Quality.Should().BeNull();
}
[Fact]
public void WriteBatchAndWaitRequest_FlagValue_IsTypedValue()
{
var req = new WriteBatchAndWaitRequest
{
SessionId = "abc",
FlagTag = "Motor.Done",
FlagValue = new TypedValue { BoolValue = true },
TimeoutMs = 5000,
PollIntervalMs = 100
};
req.FlagValue.Should().NotBeNull();
req.FlagValue!.GetValueCase().Should().Be(TypedValueCase.BoolValue);
}
[Fact]
public void WriteItem_Value_IsTypedValue()
{
var item = new WriteItem
{
Tag = "Motor.Speed",
Value = new TypedValue { DoubleValue = 42.5 }
};
item.Value.Should().NotBeNull();
item.Value!.GetValueCase().Should().Be(TypedValueCase.DoubleValue);
}
}

View File

@@ -0,0 +1,33 @@
using FluentAssertions;
using Xunit;
using ZB.MOM.WW.LmxProxy.Client.Domain;
namespace ZB.MOM.WW.LmxProxy.Client.Tests.Domain;
public class VtqTests
{
[Fact]
public void Good_FactoryMethod()
{
var vtq = Vtq.Good(42.0);
vtq.Value.Should().Be(42.0);
vtq.Quality.Should().Be(Quality.Good);
vtq.Timestamp.Kind.Should().Be(DateTimeKind.Utc);
}
[Fact]
public void Bad_FactoryMethod()
{
var vtq = Vtq.Bad();
vtq.Value.Should().BeNull();
vtq.Quality.Should().Be(Quality.Bad);
}
[Fact]
public void Uncertain_FactoryMethod()
{
var vtq = Vtq.Uncertain("stale");
vtq.Value.Should().Be("stale");
vtq.Quality.Should().Be(Quality.Uncertain);
}
}

View File

@@ -0,0 +1,91 @@
using ZB.MOM.WW.LmxProxy.Client.Domain;
namespace ZB.MOM.WW.LmxProxy.Client.Tests.Fakes;
/// <summary>
/// Hand-written fake implementation of ILmxProxyClient for unit testing streaming extensions.
/// </summary>
internal class FakeLmxProxyClient : ILmxProxyClient
{
public TimeSpan DefaultTimeout { get; set; } = TimeSpan.FromSeconds(30);
// Track calls
public List<List<string>> ReadBatchCalls { get; } = [];
public List<IDictionary<string, TypedValue>> WriteBatchCalls { get; } = [];
public List<IEnumerable<string>> SubscribeCalls { get; } = [];
// Configurable responses
public Func<IEnumerable<string>, CancellationToken, Task<IDictionary<string, Vtq>>>? ReadBatchHandler { get; set; }
public Exception? ReadBatchExceptionToThrow { get; set; }
public int ReadBatchExceptionCount { get; set; }
private int _readBatchCallCount;
// Subscription support
public Action<string, Vtq>? CapturedOnUpdate { get; private set; }
public Action<Exception>? CapturedOnError { get; private set; }
public Task ConnectAsync(CancellationToken cancellationToken = default) => Task.CompletedTask;
public Task DisconnectAsync() => Task.CompletedTask;
public Task<bool> IsConnectedAsync() => Task.FromResult(true);
public Task<Vtq> ReadAsync(string address, CancellationToken cancellationToken = default)
=> Task.FromResult(new Vtq(null, DateTime.UtcNow, Quality.Good));
public Task<IDictionary<string, Vtq>> ReadBatchAsync(IEnumerable<string> addresses, CancellationToken cancellationToken = default)
{
var addressList = addresses.ToList();
ReadBatchCalls.Add(addressList);
_readBatchCallCount++;
if (ReadBatchExceptionToThrow is not null && _readBatchCallCount <= ReadBatchExceptionCount)
throw ReadBatchExceptionToThrow;
if (ReadBatchHandler is not null)
return ReadBatchHandler(addressList, cancellationToken);
var result = new Dictionary<string, Vtq>();
foreach (var addr in addressList)
result[addr] = new Vtq(42.0, DateTime.UtcNow, Quality.Good);
return Task.FromResult<IDictionary<string, Vtq>>(result);
}
public Task WriteAsync(string address, TypedValue value, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
public Task WriteBatchAsync(IDictionary<string, TypedValue> values, CancellationToken cancellationToken = default)
{
WriteBatchCalls.Add(new Dictionary<string, TypedValue>(values));
return Task.CompletedTask;
}
public Task<WriteBatchAndWaitResponse> WriteBatchAndWaitAsync(
IDictionary<string, TypedValue> values, string flagTag, TypedValue flagValue,
int timeoutMs = 5000, int pollIntervalMs = 100, CancellationToken cancellationToken = default)
=> Task.FromResult(new WriteBatchAndWaitResponse { Success = true });
public Task<LmxProxyClient.ISubscription> SubscribeAsync(
IEnumerable<string> addresses,
Action<string, Vtq> onUpdate,
Action<Exception>? onStreamError = null,
CancellationToken cancellationToken = default)
{
SubscribeCalls.Add(addresses);
CapturedOnUpdate = onUpdate;
CapturedOnError = onStreamError;
return Task.FromResult<LmxProxyClient.ISubscription>(new FakeSubscription());
}
public Task<LmxProxyClient.ApiKeyInfo> CheckApiKeyAsync(string apiKey, CancellationToken cancellationToken = default)
=> Task.FromResult(new LmxProxyClient.ApiKeyInfo { IsValid = true });
public Dictionary<string, object> GetMetrics() => [];
public void Dispose() { }
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
private class FakeSubscription : LmxProxyClient.ISubscription
{
public void Dispose() { }
public Task DisposeAsync() => Task.CompletedTask;
}
}

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

View File

@@ -0,0 +1,106 @@
using Xunit;
namespace ZB.MOM.WW.LmxProxy.Client.Tests;
public class LmxProxyClientBuilderTests
{
[Fact]
public void Build_ThrowsWhenHostNotSet()
{
var builder = new LmxProxyClientBuilder();
Assert.Throws<InvalidOperationException>(() => builder.Build());
}
[Fact]
public void Build_DefaultPort_Is50051()
{
var client = new LmxProxyClientBuilder()
.WithHost("localhost")
.Build();
Assert.NotNull(client);
}
[Fact]
public void WithPort_ThrowsOnZero()
{
Assert.Throws<ArgumentOutOfRangeException>(() =>
new LmxProxyClientBuilder().WithPort(0));
}
[Fact]
public void WithPort_ThrowsOn65536()
{
Assert.Throws<ArgumentOutOfRangeException>(() =>
new LmxProxyClientBuilder().WithPort(65536));
}
[Fact]
public void WithTimeout_ThrowsOnNegative()
{
Assert.Throws<ArgumentOutOfRangeException>(() =>
new LmxProxyClientBuilder().WithTimeout(TimeSpan.FromSeconds(-1)));
}
[Fact]
public void WithTimeout_ThrowsOver10Minutes()
{
Assert.Throws<ArgumentOutOfRangeException>(() =>
new LmxProxyClientBuilder().WithTimeout(TimeSpan.FromMinutes(11)));
}
[Fact]
public void WithRetryPolicy_ThrowsOnZeroAttempts()
{
Assert.Throws<ArgumentOutOfRangeException>(() =>
new LmxProxyClientBuilder().WithRetryPolicy(0, TimeSpan.FromSeconds(1)));
}
[Fact]
public void WithRetryPolicy_ThrowsOnZeroDelay()
{
Assert.Throws<ArgumentOutOfRangeException>(() =>
new LmxProxyClientBuilder().WithRetryPolicy(3, TimeSpan.Zero));
}
[Fact]
public void Build_WithAllOptions_Succeeds()
{
var client = new LmxProxyClientBuilder()
.WithHost("10.100.0.48")
.WithPort(50051)
.WithApiKey("test-key")
.WithTimeout(TimeSpan.FromSeconds(15))
.WithRetryPolicy(5, TimeSpan.FromSeconds(2))
.WithMetrics()
.WithCorrelationIdHeader("X-Correlation-ID")
.Build();
Assert.NotNull(client);
}
[Fact]
public void Build_WithTls_ValidatesCertificatePaths()
{
var builder = new LmxProxyClientBuilder()
.WithHost("localhost")
.WithTlsConfiguration(new ClientTlsConfiguration
{
UseTls = true,
ServerCaCertificatePath = "/nonexistent/cert.pem"
});
Assert.Throws<FileNotFoundException>(() => builder.Build());
}
[Fact]
public void WithHost_ThrowsOnNull()
{
Assert.Throws<ArgumentException>(() =>
new LmxProxyClientBuilder().WithHost(null!));
}
[Fact]
public void WithHost_ThrowsOnEmpty()
{
Assert.Throws<ArgumentException>(() =>
new LmxProxyClientBuilder().WithHost(""));
}
}

View File

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

View File

@@ -0,0 +1,51 @@
using Microsoft.Extensions.Configuration;
using Xunit;
namespace ZB.MOM.WW.LmxProxy.Client.Tests;
public class LmxProxyClientFactoryTests
{
[Fact]
public void CreateClient_BindsFromConfiguration()
{
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["LmxProxy:Host"] = "10.100.0.48",
["LmxProxy:Port"] = "50052",
["LmxProxy:ApiKey"] = "test-key",
["LmxProxy:Retry:MaxAttempts"] = "5",
["LmxProxy:Retry:Delay"] = "00:00:02",
})
.Build();
var factory = new LmxProxyClientFactory(config);
var client = factory.CreateClient();
Assert.NotNull(client);
}
[Fact]
public void CreateClient_NamedSection()
{
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["MyProxy:Host"] = "10.100.0.48",
["MyProxy:Port"] = "50052",
})
.Build();
var factory = new LmxProxyClientFactory(config);
var client = factory.CreateClient("MyProxy");
Assert.NotNull(client);
}
[Fact]
public void CreateClient_BuilderAction()
{
var config = new ConfigurationBuilder().Build();
var factory = new LmxProxyClientFactory(config);
var client = factory.CreateClient(b => b.WithHost("localhost").WithPort(50051));
Assert.NotNull(client);
}
}

View File

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

View File

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

View File

@@ -0,0 +1,92 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Xunit;
namespace ZB.MOM.WW.LmxProxy.Client.Tests;
public class ServiceCollectionExtensionsTests
{
[Fact]
public void AddLmxProxyClient_WithConfiguration_RegistersSingleton()
{
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["LmxProxy:Host"] = "localhost",
["LmxProxy:Port"] = "50051",
})
.Build();
var services = new ServiceCollection();
services.AddLmxProxyClient(config);
using var provider = services.BuildServiceProvider();
var client = provider.GetRequiredService<ILmxProxyClient>();
Assert.NotNull(client);
Assert.IsType<LmxProxyClient>(client);
}
[Fact]
public void AddLmxProxyClient_WithBuilderAction_RegistersSingleton()
{
var services = new ServiceCollection();
services.AddLmxProxyClient(b => b.WithHost("localhost").WithPort(50051));
using var provider = services.BuildServiceProvider();
var client = provider.GetRequiredService<ILmxProxyClient>();
Assert.NotNull(client);
}
[Fact]
public void AddLmxProxyClient_WithNamedSection_RegistersSingleton()
{
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["CustomProxy:Host"] = "10.0.0.1",
["CustomProxy:Port"] = "50052",
})
.Build();
var services = new ServiceCollection();
services.AddLmxProxyClient(config, "CustomProxy");
using var provider = services.BuildServiceProvider();
var client = provider.GetRequiredService<ILmxProxyClient>();
Assert.NotNull(client);
}
[Fact]
public void AddScopedLmxProxyClient_RegistersScoped()
{
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["LmxProxy:Host"] = "localhost",
})
.Build();
var services = new ServiceCollection();
services.AddScopedLmxProxyClient(config);
using var provider = services.BuildServiceProvider();
using var scope = provider.CreateScope();
var client = scope.ServiceProvider.GetRequiredService<ILmxProxyClient>();
Assert.NotNull(client);
}
[Fact]
public void AddNamedLmxProxyClient_RegistersKeyedSingleton()
{
var services = new ServiceCollection();
services.AddNamedLmxProxyClient("primary", b => b.WithHost("host-a").WithPort(50051));
services.AddNamedLmxProxyClient("secondary", b => b.WithHost("host-b").WithPort(50052));
using var provider = services.BuildServiceProvider();
var primary = provider.GetRequiredKeyedService<ILmxProxyClient>("primary");
var secondary = provider.GetRequiredKeyedService<ILmxProxyClient>("secondary");
Assert.NotNull(primary);
Assert.NotNull(secondary);
Assert.NotSame(primary, secondary);
}
}

View File

@@ -0,0 +1,157 @@
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 StreamingExtensionsTests
{
[Fact]
public async Task ReadStreamAsync_BatchesCorrectly()
{
var fake = new FakeLmxProxyClient();
var addresses = Enumerable.Range(0, 250).Select(i => $"tag{i}").ToList();
var results = new List<KeyValuePair<string, Vtq>>();
await foreach (var kvp in fake.ReadStreamAsync(addresses, batchSize: 100))
{
results.Add(kvp);
}
// 250 tags at batchSize=100 => 3 batch calls (100, 100, 50)
Assert.Equal(3, fake.ReadBatchCalls.Count);
Assert.Equal(100, fake.ReadBatchCalls[0].Count);
Assert.Equal(100, fake.ReadBatchCalls[1].Count);
Assert.Equal(50, fake.ReadBatchCalls[2].Count);
Assert.Equal(250, results.Count);
}
[Fact]
public async Task ReadStreamAsync_RetriesOnError()
{
var fake = new FakeLmxProxyClient
{
ReadBatchExceptionToThrow = new InvalidOperationException("transient"),
ReadBatchExceptionCount = 1 // First call throws, second succeeds
};
var addresses = Enumerable.Range(0, 5).Select(i => $"tag{i}").ToList();
var results = new List<KeyValuePair<string, Vtq>>();
await foreach (var kvp in fake.ReadStreamAsync(addresses, batchSize: 10))
{
results.Add(kvp);
}
// Should retry: first call throws, second succeeds
Assert.Equal(2, fake.ReadBatchCalls.Count);
Assert.Equal(5, results.Count);
}
[Fact]
public async Task WriteStreamAsync_BatchesAndReturnsCount()
{
var fake = new FakeLmxProxyClient();
var values = GenerateWriteValues(250);
int total = await fake.WriteStreamAsync(values, batchSize: 100);
Assert.Equal(250, total);
Assert.Equal(3, fake.WriteBatchCalls.Count);
Assert.Equal(100, fake.WriteBatchCalls[0].Count);
Assert.Equal(100, fake.WriteBatchCalls[1].Count);
Assert.Equal(50, fake.WriteBatchCalls[2].Count);
}
[Fact]
public async Task ProcessInParallelAsync_RespectsMaxConcurrency()
{
int maxConcurrency = 2;
int currentConcurrency = 0;
int maxObservedConcurrency = 0;
var lockObj = new object();
var source = GenerateAsyncSequence(10);
await source.ProcessInParallelAsync(async (item, ct) =>
{
int current;
lock (lockObj)
{
currentConcurrency++;
current = currentConcurrency;
if (current > maxObservedConcurrency)
maxObservedConcurrency = current;
}
await Task.Delay(50, ct);
lock (lockObj)
{
currentConcurrency--;
}
}, maxConcurrency: maxConcurrency);
Assert.True(maxObservedConcurrency <= maxConcurrency,
$"Max observed concurrency {maxObservedConcurrency} exceeded limit {maxConcurrency}");
}
[Fact]
public async Task SubscribeStreamAsync_YieldsFromChannel()
{
var fake = new FakeLmxProxyClient();
var addresses = new[] { "tag1", "tag2" };
using var cts = new CancellationTokenSource();
var results = new List<(string Tag, Vtq Vtq)>();
// Start the subscription stream in a background task
var streamTask = Task.Run(async () =>
{
await foreach (var item in fake.SubscribeStreamAsync(addresses, cts.Token))
{
results.Add(item);
if (results.Count >= 3)
await cts.CancelAsync();
}
});
// Wait for subscribe to be called with a polling loop
for (int i = 0; i < 50 && fake.CapturedOnUpdate is null; i++)
await Task.Delay(50);
// Simulate updates via captured callback
Assert.NotNull(fake.CapturedOnUpdate);
fake.CapturedOnUpdate!("tag1", new Vtq(1.0, DateTime.UtcNow, Quality.Good));
fake.CapturedOnUpdate!("tag2", new Vtq(2.0, DateTime.UtcNow, Quality.Good));
fake.CapturedOnUpdate!("tag1", new Vtq(3.0, DateTime.UtcNow, Quality.Good));
// Wait for stream task to complete (cancelled after 3 items)
try { await streamTask; }
catch (OperationCanceledException) { }
Assert.Equal(3, results.Count);
Assert.Equal("tag1", results[0].Tag);
Assert.Equal("tag2", results[1].Tag);
Assert.Equal("tag1", results[2].Tag);
}
private static async IAsyncEnumerable<KeyValuePair<string, TypedValue>> GenerateWriteValues(int count)
{
for (int i = 0; i < count; i++)
{
yield return new KeyValuePair<string, TypedValue>(
$"tag{i}",
new TypedValue { DoubleValue = i * 1.0 });
await Task.Yield();
}
}
private static async IAsyncEnumerable<int> GenerateAsyncSequence(int count)
{
for (int i = 0; i < count; i++)
{
yield return i;
await Task.Yield();
}
}
}

View File

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

View File

@@ -0,0 +1,39 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
<RootNamespace>ZB.MOM.WW.LmxProxy.Client.Tests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="FluentAssertions" Version="7.2.0" />
<PackageReference Include="Google.Protobuf" Version="3.29.3" />
<PackageReference Include="Microsoft.Extensions.Configuration" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.0" />
<PackageReference Include="Grpc.Tools" Version="2.68.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\ZB.MOM.WW.LmxProxy.Client\ZB.MOM.WW.LmxProxy.Client.csproj" />
</ItemGroup>
<!-- Proto file for cross-stack serialization tests (Host proto → Client code-first) -->
<ItemGroup>
<Protobuf Include="..\..\src\ZB.MOM.WW.LmxProxy.Host\Grpc\Protos\scada.proto" GrpcServices="None" Link="Protos\scada.proto" />
</ItemGroup>
</Project>