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:
@@ -0,0 +1,25 @@
|
||||
namespace ZB.MOM.WW.LmxProxy.Client.IntegrationTests;
|
||||
|
||||
public class CheckApiKeyTests : IntegrationTestBase
|
||||
{
|
||||
[Fact]
|
||||
public async Task CheckApiKey_ValidReadWrite_ReturnsValid()
|
||||
{
|
||||
var info = await Client!.CheckApiKeyAsync(ReadWriteApiKey);
|
||||
Assert.True(info.IsValid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckApiKey_ValidReadOnly_ReturnsValid()
|
||||
{
|
||||
var info = await Client!.CheckApiKeyAsync(ReadOnlyApiKey);
|
||||
Assert.True(info.IsValid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckApiKey_Invalid_ReturnsInvalid()
|
||||
{
|
||||
var info = await Client!.CheckApiKeyAsync("totally-invalid-key-12345");
|
||||
Assert.False(info.IsValid);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
namespace ZB.MOM.WW.LmxProxy.Client.IntegrationTests;
|
||||
|
||||
public class ConnectionTests : IntegrationTestBase
|
||||
{
|
||||
[Fact]
|
||||
public async Task ConnectAndDisconnect_Succeeds()
|
||||
{
|
||||
// Client is connected in InitializeAsync
|
||||
Assert.True(await Client!.IsConnectedAsync());
|
||||
await Client.DisconnectAsync();
|
||||
Assert.False(await Client.IsConnectedAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConnectWithInvalidApiKey_Fails()
|
||||
{
|
||||
using var badClient = CreateClient(InvalidApiKey);
|
||||
var ex = await Assert.ThrowsAsync<Grpc.Core.RpcException>(
|
||||
() => badClient.ConnectAsync());
|
||||
Assert.Equal(Grpc.Core.StatusCode.Unauthenticated, ex.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DoubleConnect_IsIdempotent()
|
||||
{
|
||||
await Client!.ConnectAsync(); // Already connected — should be no-op
|
||||
Assert.True(await Client.IsConnectedAsync());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
global using Xunit;
|
||||
global using ZB.MOM.WW.LmxProxy.Client.Domain;
|
||||
@@ -0,0 +1,56 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using ZB.MOM.WW.LmxProxy.Client;
|
||||
|
||||
namespace ZB.MOM.WW.LmxProxy.Client.IntegrationTests;
|
||||
|
||||
public abstract class IntegrationTestBase : IAsyncLifetime
|
||||
{
|
||||
protected IConfiguration Configuration { get; }
|
||||
protected string Host { get; }
|
||||
protected int Port { get; }
|
||||
protected string ReadWriteApiKey { get; }
|
||||
protected string ReadOnlyApiKey { get; }
|
||||
protected string InvalidApiKey { get; }
|
||||
protected LmxProxyClient? Client { get; set; }
|
||||
|
||||
protected IntegrationTestBase()
|
||||
{
|
||||
Configuration = new ConfigurationBuilder()
|
||||
.AddJsonFile("appsettings.test.json")
|
||||
.Build();
|
||||
|
||||
var section = Configuration.GetSection("LmxProxy");
|
||||
Host = section["Host"] ?? "10.100.0.48";
|
||||
Port = int.Parse(section["Port"] ?? "50052");
|
||||
ReadWriteApiKey = section["ReadWriteApiKey"] ?? throw new Exception("ReadWriteApiKey not configured");
|
||||
ReadOnlyApiKey = section["ReadOnlyApiKey"] ?? throw new Exception("ReadOnlyApiKey not configured");
|
||||
InvalidApiKey = section["InvalidApiKey"] ?? "invalid-key";
|
||||
}
|
||||
|
||||
protected LmxProxyClient CreateClient(string? apiKey = null)
|
||||
{
|
||||
return new LmxProxyClientBuilder()
|
||||
.WithHost(Host)
|
||||
.WithPort(Port)
|
||||
.WithApiKey(apiKey ?? ReadWriteApiKey)
|
||||
.WithTimeout(TimeSpan.FromSeconds(10))
|
||||
.WithRetryPolicy(2, TimeSpan.FromSeconds(1))
|
||||
.WithMetrics()
|
||||
.Build();
|
||||
}
|
||||
|
||||
public virtual async Task InitializeAsync()
|
||||
{
|
||||
Client = CreateClient();
|
||||
await Client.ConnectAsync();
|
||||
}
|
||||
|
||||
public virtual async Task DisposeAsync()
|
||||
{
|
||||
if (Client is not null)
|
||||
{
|
||||
await Client.DisconnectAsync();
|
||||
Client.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
namespace ZB.MOM.WW.LmxProxy.Client.IntegrationTests;
|
||||
|
||||
public class ReadTests : IntegrationTestBase
|
||||
{
|
||||
[Fact]
|
||||
public async Task Read_BoolTag_ReturnsBoolValue()
|
||||
{
|
||||
var vtq = await Client!.ReadAsync("TestChildObject.TestBool");
|
||||
Assert.IsType<bool>(vtq.Value);
|
||||
Assert.True(vtq.Quality.IsGood());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Read_IntTag_ReturnsIntValue()
|
||||
{
|
||||
var vtq = await Client!.ReadAsync("TestChildObject.TestInt");
|
||||
Assert.True(vtq.Value is int or long);
|
||||
Assert.True(vtq.Quality.IsGood());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Read_FloatTag_ReturnsFloatValue()
|
||||
{
|
||||
var vtq = await Client!.ReadAsync("TestChildObject.TestFloat");
|
||||
Assert.True(vtq.Value is float or double);
|
||||
Assert.True(vtq.Quality.IsGood());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Read_DoubleTag_ReturnsDoubleValue()
|
||||
{
|
||||
var vtq = await Client!.ReadAsync("TestChildObject.TestDouble");
|
||||
Assert.IsType<double>(vtq.Value);
|
||||
Assert.True(vtq.Quality.IsGood());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Read_StringTag_ReturnsStringValue()
|
||||
{
|
||||
var vtq = await Client!.ReadAsync("TestChildObject.TestString");
|
||||
Assert.IsType<string>(vtq.Value);
|
||||
Assert.True(vtq.Quality.IsGood());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Read_DateTimeTag_ReturnsDateTimeValue()
|
||||
{
|
||||
var vtq = await Client!.ReadAsync("TestChildObject.TestDateTime");
|
||||
Assert.IsType<DateTime>(vtq.Value);
|
||||
Assert.True(vtq.Quality.IsGood());
|
||||
Assert.True(DateTime.UtcNow - vtq.Timestamp < TimeSpan.FromHours(1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadBatch_MultipleTags_ReturnsDictionary()
|
||||
{
|
||||
var tags = new[] { "TestChildObject.TestString", "TestChildObject.TestInt" };
|
||||
var results = await Client!.ReadBatchAsync(tags);
|
||||
Assert.Equal(2, results.Count);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
using ZB.MOM.WW.LmxProxy.Client.Domain;
|
||||
|
||||
namespace ZB.MOM.WW.LmxProxy.Client.IntegrationTests;
|
||||
|
||||
public class SubscribeTests : IntegrationTestBase
|
||||
{
|
||||
[Fact]
|
||||
public async Task Subscribe_ReceivesUpdates()
|
||||
{
|
||||
var received = new List<(string Tag, Vtq Vtq)>();
|
||||
var receivedEvent = new TaskCompletionSource<bool>();
|
||||
|
||||
var subscription = await Client!.SubscribeAsync(
|
||||
new[] { "TestChildObject.TestInt" },
|
||||
(tag, vtq) =>
|
||||
{
|
||||
received.Add((tag, vtq));
|
||||
if (received.Count >= 3)
|
||||
receivedEvent.TrySetResult(true);
|
||||
},
|
||||
ex => receivedEvent.TrySetException(ex));
|
||||
|
||||
// Wait up to 30 seconds for at least 3 updates
|
||||
var completed = await Task.WhenAny(receivedEvent.Task, Task.Delay(TimeSpan.FromSeconds(30)));
|
||||
subscription.Dispose();
|
||||
|
||||
Assert.True(received.Count >= 1, $"Expected at least 1 update, got {received.Count}");
|
||||
|
||||
var first = received[0];
|
||||
Assert.Equal("TestChildObject.TestInt", first.Tag);
|
||||
Assert.NotNull(first.Vtq.Value);
|
||||
Assert.True(first.Vtq.Timestamp > DateTime.MinValue);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using ZB.MOM.WW.LmxProxy.Client.Domain;
|
||||
|
||||
namespace ZB.MOM.WW.LmxProxy.Client.IntegrationTests;
|
||||
|
||||
public class WriteBatchAndWaitTests : IntegrationTestBase
|
||||
{
|
||||
[Fact]
|
||||
public async Task WriteBatchAndWait_TypeAwareComparison()
|
||||
{
|
||||
var values = new Dictionary<string, TypedValue>
|
||||
{
|
||||
["TestChildObject.TestString"] = new TypedValue { StringValue = "BatchTest" }
|
||||
};
|
||||
|
||||
var response = await Client!.WriteBatchAndWaitAsync(
|
||||
values,
|
||||
flagTag: "TestChildObject.TestString",
|
||||
flagValue: new TypedValue { StringValue = "BatchTest" },
|
||||
timeoutMs: 5000,
|
||||
pollIntervalMs: 200);
|
||||
|
||||
Assert.True(response.Success);
|
||||
Assert.True(response.FlagReached);
|
||||
Assert.True(response.ElapsedMs < 5000);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using ZB.MOM.WW.LmxProxy.Client.Domain;
|
||||
|
||||
namespace ZB.MOM.WW.LmxProxy.Client.IntegrationTests;
|
||||
|
||||
public class WriteTests : IntegrationTestBase
|
||||
{
|
||||
[Fact]
|
||||
public async Task WriteAndReadBack_StringValue()
|
||||
{
|
||||
string testValue = $"IntTest-{DateTime.UtcNow:HHmmss}";
|
||||
await Client!.WriteAsync("TestChildObject.TestString",
|
||||
new TypedValue { StringValue = testValue });
|
||||
|
||||
await Task.Delay(500); // Allow time for write to propagate
|
||||
var vtq = await Client.ReadAsync("TestChildObject.TestString");
|
||||
Assert.Equal(testValue, vtq.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WriteWithReadOnlyKey_ThrowsPermissionDenied()
|
||||
{
|
||||
using var readOnlyClient = CreateClient(ReadOnlyApiKey);
|
||||
await readOnlyClient.ConnectAsync();
|
||||
|
||||
var ex = await Assert.ThrowsAsync<Grpc.Core.RpcException>(
|
||||
() => readOnlyClient.WriteAsync("TestChildObject.TestString",
|
||||
new TypedValue { StringValue = "should-fail" }));
|
||||
Assert.Equal(Grpc.Core.StatusCode.PermissionDenied, ex.StatusCode);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\ZB.MOM.WW.LmxProxy.Client\ZB.MOM.WW.LmxProxy.Client.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Update="appsettings.test.json">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"LmxProxy": {
|
||||
"Host": "localhost",
|
||||
"Port": 50100,
|
||||
"ReadWriteApiKey": "c4559c7c6acc60a997135c1381162e3c30f4572ece78dd933c1a626e6fd933b4",
|
||||
"ReadOnlyApiKey": "a77d090d4adcfeaac1a50379ec5f971ff282c998599fd8ccf410090c9f290150",
|
||||
"InvalidApiKey": "invalid-key-that-does-not-exist"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"$schema": "https://xunit.net/schema/current/xunit.runner.schema.json",
|
||||
"parallelizeAssembly": false,
|
||||
"parallelizeTestCollections": false
|
||||
}
|
||||
@@ -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,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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,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(""));
|
||||
}
|
||||
}
|
||||
@@ -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,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);
|
||||
}
|
||||
}
|
||||
@@ -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,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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,77 @@
|
||||
using System;
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.LmxProxy.Host.Configuration;
|
||||
|
||||
namespace ZB.MOM.WW.LmxProxy.Host.Tests.Configuration
|
||||
{
|
||||
public class ConfigurationValidatorTests
|
||||
{
|
||||
private static LmxProxyConfiguration ValidConfig() => new LmxProxyConfiguration();
|
||||
|
||||
[Fact]
|
||||
public void ValidConfig_PassesValidation()
|
||||
{
|
||||
var config = ValidConfig();
|
||||
Action act = () => ConfigurationValidator.ValidateAndLog(config);
|
||||
act.Should().NotThrow();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(-1)]
|
||||
[InlineData(70000)]
|
||||
public void InvalidGrpcPort_Throws(int port)
|
||||
{
|
||||
var config = ValidConfig();
|
||||
config.GrpcPort = port;
|
||||
Action act = () => ConfigurationValidator.ValidateAndLog(config);
|
||||
act.Should().Throw<InvalidOperationException>().Where(e => e.Message.Contains("GrpcPort"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvalidMonitorInterval_Throws()
|
||||
{
|
||||
var config = ValidConfig();
|
||||
config.Connection.MonitorIntervalSeconds = 0;
|
||||
Action act = () => ConfigurationValidator.ValidateAndLog(config);
|
||||
act.Should().Throw<InvalidOperationException>().Where(e => e.Message.Contains("MonitorIntervalSeconds"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvalidChannelCapacity_Throws()
|
||||
{
|
||||
var config = ValidConfig();
|
||||
config.Subscription.ChannelCapacity = -1;
|
||||
Action act = () => ConfigurationValidator.ValidateAndLog(config);
|
||||
act.Should().Throw<InvalidOperationException>().Where(e => e.Message.Contains("ChannelCapacity"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvalidChannelFullMode_Throws()
|
||||
{
|
||||
var config = ValidConfig();
|
||||
config.Subscription.ChannelFullMode = "InvalidMode";
|
||||
Action act = () => ConfigurationValidator.ValidateAndLog(config);
|
||||
act.Should().Throw<InvalidOperationException>().Where(e => e.Message.Contains("ChannelFullMode"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvalidResetPeriodDays_Throws()
|
||||
{
|
||||
var config = ValidConfig();
|
||||
config.ServiceRecovery.ResetPeriodDays = 0;
|
||||
Action act = () => ConfigurationValidator.ValidateAndLog(config);
|
||||
act.Should().Throw<InvalidOperationException>().Where(e => e.Message.Contains("ResetPeriodDays"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NegativeFailureDelay_Throws()
|
||||
{
|
||||
var config = ValidConfig();
|
||||
config.ServiceRecovery.FirstFailureDelayMinutes = -1;
|
||||
Action act = () => ConfigurationValidator.ValidateAndLog(config);
|
||||
act.Should().Throw<InvalidOperationException>().Where(e => e.Message.Contains("FirstFailureDelayMinutes"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.LmxProxy.Host.Domain;
|
||||
|
||||
namespace ZB.MOM.WW.LmxProxy.Host.Tests.Domain
|
||||
{
|
||||
public class QualityCodeMapperTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(Quality.Good, 0x00000000u, "Good")]
|
||||
[InlineData(Quality.Good_LocalOverride, 0x00D80000u, "GoodLocalOverride")]
|
||||
[InlineData(Quality.Bad, 0x80000000u, "Bad")]
|
||||
[InlineData(Quality.Bad_ConfigError, 0x80040000u, "BadConfigurationError")]
|
||||
[InlineData(Quality.Bad_NotConnected, 0x808A0000u, "BadNotConnected")]
|
||||
[InlineData(Quality.Bad_DeviceFailure, 0x806B0000u, "BadDeviceFailure")]
|
||||
[InlineData(Quality.Bad_SensorFailure, 0x806D0000u, "BadSensorFailure")]
|
||||
[InlineData(Quality.Bad_CommFailure, 0x80050000u, "BadCommunicationFailure")]
|
||||
[InlineData(Quality.Bad_OutOfService, 0x808F0000u, "BadOutOfService")]
|
||||
[InlineData(Quality.Bad_WaitingForInitialData, 0x80320000u, "BadWaitingForInitialData")]
|
||||
[InlineData(Quality.Uncertain_LastUsable, 0x40900000u, "UncertainLastUsableValue")]
|
||||
[InlineData(Quality.Uncertain_SensorNotAcc, 0x42390000u, "UncertainSensorNotAccurate")]
|
||||
[InlineData(Quality.Uncertain_EuExceeded, 0x40540000u, "UncertainEngineeringUnitsExceeded")]
|
||||
[InlineData(Quality.Uncertain_SubNormal, 0x40580000u, "UncertainSubNormal")]
|
||||
public void ToQualityCode_MapsCorrectly(Quality quality, uint expectedStatusCode, string expectedName)
|
||||
{
|
||||
var qc = QualityCodeMapper.ToQualityCode(quality);
|
||||
qc.StatusCode.Should().Be(expectedStatusCode);
|
||||
qc.SymbolicName.Should().Be(expectedName);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0x00000000u, Quality.Good)]
|
||||
[InlineData(0x80000000u, Quality.Bad)]
|
||||
[InlineData(0x80040000u, Quality.Bad_ConfigError)]
|
||||
[InlineData(0x806D0000u, Quality.Bad_SensorFailure)]
|
||||
[InlineData(0x40900000u, Quality.Uncertain_LastUsable)]
|
||||
public void FromStatusCode_MapsCorrectly(uint statusCode, Quality expectedQuality)
|
||||
{
|
||||
QualityCodeMapper.FromStatusCode(statusCode).Should().Be(expectedQuality);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromStatusCode_UnknownGoodCode_FallsBackToGood()
|
||||
{
|
||||
QualityCodeMapper.FromStatusCode(0x00FF0000).Should().Be(Quality.Good);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromStatusCode_UnknownBadCode_FallsBackToBad()
|
||||
{
|
||||
QualityCodeMapper.FromStatusCode(0x80FF0000).Should().Be(Quality.Bad);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromStatusCode_UnknownUncertainCode_FallsBackToUncertain()
|
||||
{
|
||||
QualityCodeMapper.FromStatusCode(0x40FF0000).Should().Be(Quality.Uncertain);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0x00000000u, "Good")]
|
||||
[InlineData(0x80000000u, "Bad")]
|
||||
[InlineData(0x806D0000u, "BadSensorFailure")]
|
||||
[InlineData(0x40900000u, "UncertainLastUsableValue")]
|
||||
[InlineData(0x80FF0000u, "Bad")] // unknown bad code falls back
|
||||
public void GetSymbolicName_ReturnsCorrectName(uint statusCode, string expectedName)
|
||||
{
|
||||
QualityCodeMapper.GetSymbolicName(statusCode).Should().Be(expectedName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FactoryMethods_ReturnCorrectCodes()
|
||||
{
|
||||
QualityCodeMapper.Good().StatusCode.Should().Be(0x00000000u);
|
||||
QualityCodeMapper.Bad().StatusCode.Should().Be(0x80000000u);
|
||||
QualityCodeMapper.BadConfigurationError().StatusCode.Should().Be(0x80040000u);
|
||||
QualityCodeMapper.BadCommunicationFailure().StatusCode.Should().Be(0x80050000u);
|
||||
QualityCodeMapper.BadNotConnected().StatusCode.Should().Be(0x808A0000u);
|
||||
QualityCodeMapper.BadDeviceFailure().StatusCode.Should().Be(0x806B0000u);
|
||||
QualityCodeMapper.BadSensorFailure().StatusCode.Should().Be(0x806D0000u);
|
||||
QualityCodeMapper.BadOutOfService().StatusCode.Should().Be(0x808F0000u);
|
||||
QualityCodeMapper.BadWaitingForInitialData().StatusCode.Should().Be(0x80320000u);
|
||||
QualityCodeMapper.GoodLocalOverride().StatusCode.Should().Be(0x00D80000u);
|
||||
QualityCodeMapper.UncertainLastUsableValue().StatusCode.Should().Be(0x40900000u);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.LmxProxy.Host.Domain;
|
||||
|
||||
namespace ZB.MOM.WW.LmxProxy.Host.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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
using System;
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.LmxProxy.Host.Domain;
|
||||
|
||||
namespace ZB.MOM.WW.LmxProxy.Host.Tests.Domain
|
||||
{
|
||||
public class TypedValueConverterTests
|
||||
{
|
||||
[Fact]
|
||||
public void Null_RoundTrips()
|
||||
{
|
||||
var tv = TypedValueConverter.ToTypedValue(null);
|
||||
tv.Should().BeNull();
|
||||
TypedValueConverter.FromTypedValue(null).Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DBNull_MapsToNull()
|
||||
{
|
||||
var tv = TypedValueConverter.ToTypedValue(DBNull.Value);
|
||||
tv.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Bool_RoundTrips()
|
||||
{
|
||||
var tv = TypedValueConverter.ToTypedValue(true);
|
||||
tv.Should().NotBeNull();
|
||||
tv!.ValueCase.Should().Be(Scada.TypedValue.ValueOneofCase.BoolValue);
|
||||
tv.BoolValue.Should().BeTrue();
|
||||
TypedValueConverter.FromTypedValue(tv).Should().Be(true);
|
||||
|
||||
var tvFalse = TypedValueConverter.ToTypedValue(false);
|
||||
tvFalse!.BoolValue.Should().BeFalse();
|
||||
TypedValueConverter.FromTypedValue(tvFalse).Should().Be(false);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Short_WidensToInt32()
|
||||
{
|
||||
var tv = TypedValueConverter.ToTypedValue((short)42);
|
||||
tv!.ValueCase.Should().Be(Scada.TypedValue.ValueOneofCase.Int32Value);
|
||||
tv.Int32Value.Should().Be(42);
|
||||
TypedValueConverter.FromTypedValue(tv).Should().Be(42);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Int_RoundTrips()
|
||||
{
|
||||
var tv = TypedValueConverter.ToTypedValue(int.MaxValue);
|
||||
tv!.ValueCase.Should().Be(Scada.TypedValue.ValueOneofCase.Int32Value);
|
||||
tv.Int32Value.Should().Be(int.MaxValue);
|
||||
TypedValueConverter.FromTypedValue(tv).Should().Be(int.MaxValue);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Long_RoundTrips()
|
||||
{
|
||||
var tv = TypedValueConverter.ToTypedValue(long.MaxValue);
|
||||
tv!.ValueCase.Should().Be(Scada.TypedValue.ValueOneofCase.Int64Value);
|
||||
tv.Int64Value.Should().Be(long.MaxValue);
|
||||
TypedValueConverter.FromTypedValue(tv).Should().Be(long.MaxValue);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UShort_WidensToInt32()
|
||||
{
|
||||
var tv = TypedValueConverter.ToTypedValue((ushort)65535);
|
||||
tv!.ValueCase.Should().Be(Scada.TypedValue.ValueOneofCase.Int32Value);
|
||||
tv.Int32Value.Should().Be(65535);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UInt_WidensToInt64()
|
||||
{
|
||||
var tv = TypedValueConverter.ToTypedValue(uint.MaxValue);
|
||||
tv!.ValueCase.Should().Be(Scada.TypedValue.ValueOneofCase.Int64Value);
|
||||
tv.Int64Value.Should().Be(uint.MaxValue);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ULong_MapsToInt64()
|
||||
{
|
||||
var tv = TypedValueConverter.ToTypedValue((ulong)12345678);
|
||||
tv!.ValueCase.Should().Be(Scada.TypedValue.ValueOneofCase.Int64Value);
|
||||
tv.Int64Value.Should().Be(12345678);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Float_RoundTrips()
|
||||
{
|
||||
var tv = TypedValueConverter.ToTypedValue(3.14159f);
|
||||
tv!.ValueCase.Should().Be(Scada.TypedValue.ValueOneofCase.FloatValue);
|
||||
tv.FloatValue.Should().Be(3.14159f);
|
||||
TypedValueConverter.FromTypedValue(tv).Should().Be(3.14159f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Double_RoundTrips()
|
||||
{
|
||||
var tv = TypedValueConverter.ToTypedValue(2.718281828459045);
|
||||
tv!.ValueCase.Should().Be(Scada.TypedValue.ValueOneofCase.DoubleValue);
|
||||
tv.DoubleValue.Should().Be(2.718281828459045);
|
||||
TypedValueConverter.FromTypedValue(tv).Should().Be(2.718281828459045);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void String_RoundTrips()
|
||||
{
|
||||
var tv = TypedValueConverter.ToTypedValue("Hello World");
|
||||
tv!.ValueCase.Should().Be(Scada.TypedValue.ValueOneofCase.StringValue);
|
||||
tv.StringValue.Should().Be("Hello World");
|
||||
TypedValueConverter.FromTypedValue(tv).Should().Be("Hello World");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DateTime_RoundTrips_AsUtcTicks()
|
||||
{
|
||||
var dt = new DateTime(2026, 3, 21, 12, 0, 0, DateTimeKind.Utc);
|
||||
var tv = TypedValueConverter.ToTypedValue(dt);
|
||||
tv!.ValueCase.Should().Be(Scada.TypedValue.ValueOneofCase.DatetimeValue);
|
||||
tv.DatetimeValue.Should().Be(dt.Ticks);
|
||||
var result = (DateTime)TypedValueConverter.FromTypedValue(tv)!;
|
||||
result.Kind.Should().Be(DateTimeKind.Utc);
|
||||
result.Ticks.Should().Be(dt.Ticks);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ByteArray_RoundTrips()
|
||||
{
|
||||
var bytes = new byte[] { 0x00, 0xFF, 0x42 };
|
||||
var tv = TypedValueConverter.ToTypedValue(bytes);
|
||||
tv!.ValueCase.Should().Be(Scada.TypedValue.ValueOneofCase.BytesValue);
|
||||
var result = (byte[])TypedValueConverter.FromTypedValue(tv)!;
|
||||
result.Should().BeEquivalentTo(bytes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Decimal_MapsToDouble()
|
||||
{
|
||||
var tv = TypedValueConverter.ToTypedValue(123.456m);
|
||||
tv!.ValueCase.Should().Be(Scada.TypedValue.ValueOneofCase.DoubleValue);
|
||||
tv.DoubleValue.Should().BeApproximately(123.456, 0.001);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FloatArray_RoundTrips()
|
||||
{
|
||||
var arr = new float[] { 1.0f, 2.0f, 3.0f };
|
||||
var tv = TypedValueConverter.ToTypedValue(arr);
|
||||
tv!.ValueCase.Should().Be(Scada.TypedValue.ValueOneofCase.ArrayValue);
|
||||
var result = (float[])TypedValueConverter.FromTypedValue(tv)!;
|
||||
result.Should().BeEquivalentTo(arr);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IntArray_RoundTrips()
|
||||
{
|
||||
var arr = new int[] { 10, 20, 30 };
|
||||
var tv = TypedValueConverter.ToTypedValue(arr);
|
||||
tv!.ValueCase.Should().Be(Scada.TypedValue.ValueOneofCase.ArrayValue);
|
||||
var result = (int[])TypedValueConverter.FromTypedValue(tv)!;
|
||||
result.Should().BeEquivalentTo(arr);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StringArray_RoundTrips()
|
||||
{
|
||||
var arr = new string[] { "a", "b", "c" };
|
||||
var tv = TypedValueConverter.ToTypedValue(arr);
|
||||
tv!.ValueCase.Should().Be(Scada.TypedValue.ValueOneofCase.ArrayValue);
|
||||
var result = (string[])TypedValueConverter.FromTypedValue(tv)!;
|
||||
result.Should().BeEquivalentTo(arr);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DoubleArray_RoundTrips()
|
||||
{
|
||||
var arr = new double[] { 1.1, 2.2, 3.3 };
|
||||
var tv = TypedValueConverter.ToTypedValue(arr);
|
||||
tv!.ValueCase.Should().Be(Scada.TypedValue.ValueOneofCase.ArrayValue);
|
||||
var result = (double[])TypedValueConverter.FromTypedValue(tv)!;
|
||||
result.Should().BeEquivalentTo(arr);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnrecognizedType_FallsBackToString()
|
||||
{
|
||||
var guid = Guid.NewGuid();
|
||||
var tv = TypedValueConverter.ToTypedValue(guid);
|
||||
tv!.ValueCase.Should().Be(Scada.TypedValue.ValueOneofCase.StringValue);
|
||||
tv.StringValue.Should().Be(guid.ToString());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.LmxProxy.Host.Domain;
|
||||
using ZB.MOM.WW.LmxProxy.Host.Health;
|
||||
using HealthCheckService = ZB.MOM.WW.LmxProxy.Host.Health.HealthCheckService;
|
||||
using ZB.MOM.WW.LmxProxy.Host.Metrics;
|
||||
using ZB.MOM.WW.LmxProxy.Host.Subscriptions;
|
||||
|
||||
namespace ZB.MOM.WW.LmxProxy.Host.Tests.Health
|
||||
{
|
||||
public class HealthCheckServiceTests
|
||||
{
|
||||
private class FakeScadaClient : IScadaClient
|
||||
{
|
||||
public bool IsConnected { get; set; } = true;
|
||||
public ConnectionState ConnectionState { get; set; } = ConnectionState.Connected;
|
||||
public DateTime ConnectedSince => DateTime.UtcNow;
|
||||
public int ReconnectCount => 0;
|
||||
public event EventHandler<ConnectionStateChangedEventArgs>? ConnectionStateChanged;
|
||||
public Task ConnectAsync(CancellationToken ct = default) => Task.CompletedTask;
|
||||
public Task DisconnectAsync(CancellationToken ct = default) => Task.CompletedTask;
|
||||
public Task<Vtq> ReadAsync(string address, CancellationToken ct = default) =>
|
||||
Task.FromResult(Vtq.Good(42.0));
|
||||
public Task<IReadOnlyDictionary<string, Vtq>> ReadBatchAsync(IEnumerable<string> addresses, CancellationToken ct = default) =>
|
||||
Task.FromResult<IReadOnlyDictionary<string, Vtq>>(new Dictionary<string, Vtq>());
|
||||
public Task WriteAsync(string address, object value, CancellationToken ct = default) => Task.CompletedTask;
|
||||
public Task WriteBatchAsync(IReadOnlyDictionary<string, object> values, CancellationToken ct = default) => Task.CompletedTask;
|
||||
public Task<(bool flagReached, int elapsedMs)> WriteBatchAndWaitAsync(
|
||||
IReadOnlyDictionary<string, object> values, string flagTag, object flagValue,
|
||||
int timeoutMs, int pollIntervalMs, CancellationToken ct = default) =>
|
||||
Task.FromResult((false, 0));
|
||||
public Task UnsubscribeByAddressAsync(IEnumerable<string> addresses) => Task.CompletedTask;
|
||||
public Task<IAsyncDisposable> SubscribeAsync(IEnumerable<string> addresses, Action<string, Vtq> callback, CancellationToken ct = default) =>
|
||||
Task.FromResult<IAsyncDisposable>(new FakeHandle());
|
||||
public ValueTask DisposeAsync() => default;
|
||||
internal void FireEvent() => ConnectionStateChanged?.Invoke(this, null!);
|
||||
private class FakeHandle : IAsyncDisposable { public ValueTask DisposeAsync() => default; }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReturnsHealthy_WhenConnectedAndNormalMetrics()
|
||||
{
|
||||
var client = new FakeScadaClient { IsConnected = true, ConnectionState = ConnectionState.Connected };
|
||||
using var sm = new SubscriptionManager(client);
|
||||
using var pm = new PerformanceMetrics();
|
||||
pm.RecordOperation("Read", TimeSpan.FromMilliseconds(10), true);
|
||||
|
||||
var svc = new HealthCheckService(client, sm, pm);
|
||||
|
||||
var result = await svc.CheckHealthAsync(new HealthCheckContext());
|
||||
|
||||
result.Status.Should().Be(HealthStatus.Healthy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReturnsUnhealthy_WhenNotConnected()
|
||||
{
|
||||
var client = new FakeScadaClient { IsConnected = false, ConnectionState = ConnectionState.Disconnected };
|
||||
using var sm = new SubscriptionManager(client);
|
||||
using var pm = new PerformanceMetrics();
|
||||
|
||||
var svc = new HealthCheckService(client, sm, pm);
|
||||
|
||||
var result = await svc.CheckHealthAsync(new HealthCheckContext());
|
||||
|
||||
result.Status.Should().Be(HealthStatus.Unhealthy);
|
||||
result.Description.Should().Contain("not connected");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReturnsDegraded_WhenSuccessRateBelow50Percent()
|
||||
{
|
||||
var client = new FakeScadaClient { IsConnected = true };
|
||||
using var sm = new SubscriptionManager(client);
|
||||
using var pm = new PerformanceMetrics();
|
||||
|
||||
// Record 200 operations with 40% success rate
|
||||
for (int i = 0; i < 80; i++)
|
||||
pm.RecordOperation("Read", TimeSpan.FromMilliseconds(10), true);
|
||||
for (int i = 0; i < 120; i++)
|
||||
pm.RecordOperation("Read", TimeSpan.FromMilliseconds(10), false);
|
||||
|
||||
var svc = new HealthCheckService(client, sm, pm);
|
||||
|
||||
var result = await svc.CheckHealthAsync(new HealthCheckContext());
|
||||
|
||||
result.Status.Should().Be(HealthStatus.Degraded);
|
||||
result.Description.Should().Contain("success rate");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReturnsDegraded_WhenClientCountOver100()
|
||||
{
|
||||
var client = new FakeScadaClient { IsConnected = true };
|
||||
using var sm = new SubscriptionManager(client);
|
||||
using var pm = new PerformanceMetrics();
|
||||
|
||||
// Create 101 subscriptions to exceed the threshold
|
||||
for (int i = 0; i < 101; i++)
|
||||
{
|
||||
using var cts = new CancellationTokenSource();
|
||||
await sm.SubscribeAsync("client-" + i, new[] { "tag1" }, cts.Token);
|
||||
}
|
||||
|
||||
var svc = new HealthCheckService(client, sm, pm);
|
||||
|
||||
var result = await svc.CheckHealthAsync(new HealthCheckContext());
|
||||
|
||||
result.Status.Should().Be(HealthStatus.Degraded);
|
||||
result.Description.Should().Contain("client count");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DoesNotFlagLowSuccessRate_Under100Operations()
|
||||
{
|
||||
var client = new FakeScadaClient { IsConnected = true };
|
||||
using var sm = new SubscriptionManager(client);
|
||||
using var pm = new PerformanceMetrics();
|
||||
|
||||
// Record 50 operations with 0% success rate (under 100 threshold)
|
||||
for (int i = 0; i < 50; i++)
|
||||
pm.RecordOperation("Read", TimeSpan.FromMilliseconds(10), false);
|
||||
|
||||
var svc = new HealthCheckService(client, sm, pm);
|
||||
|
||||
var result = await svc.CheckHealthAsync(new HealthCheckContext());
|
||||
|
||||
result.Status.Should().Be(HealthStatus.Healthy);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.LmxProxy.Host.Metrics;
|
||||
|
||||
namespace ZB.MOM.WW.LmxProxy.Host.Tests.Metrics
|
||||
{
|
||||
public class PerformanceMetricsTests
|
||||
{
|
||||
[Fact]
|
||||
public void RecordOperation_TracksCountAndDuration()
|
||||
{
|
||||
using var metrics = new PerformanceMetrics();
|
||||
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
metrics.RecordOperation("TestOp", TimeSpan.FromMilliseconds(10), true);
|
||||
}
|
||||
|
||||
var stats = metrics.GetStatistics();
|
||||
stats.Should().ContainKey("TestOp");
|
||||
stats["TestOp"].TotalCount.Should().Be(5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecordOperation_TracksSuccessAndFailure()
|
||||
{
|
||||
using var metrics = new PerformanceMetrics();
|
||||
|
||||
for (int i = 0; i < 3; i++)
|
||||
{
|
||||
metrics.RecordOperation("TestOp", TimeSpan.FromMilliseconds(10), true);
|
||||
}
|
||||
for (int i = 0; i < 2; i++)
|
||||
{
|
||||
metrics.RecordOperation("TestOp", TimeSpan.FromMilliseconds(10), false);
|
||||
}
|
||||
|
||||
var stats = metrics.GetStatistics();
|
||||
stats["TestOp"].SuccessRate.Should().BeApproximately(0.6, 0.001);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetStatistics_CalculatesP95Correctly()
|
||||
{
|
||||
using var metrics = new PerformanceMetrics();
|
||||
|
||||
for (int i = 1; i <= 100; i++)
|
||||
{
|
||||
metrics.RecordOperation("TestOp", TimeSpan.FromMilliseconds(i), true);
|
||||
}
|
||||
|
||||
var stats = metrics.GetStatistics();
|
||||
stats["TestOp"].Percentile95Milliseconds.Should().BeApproximately(95.0, 1.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RollingBuffer_CapsAt1000Samples()
|
||||
{
|
||||
using var metrics = new PerformanceMetrics();
|
||||
|
||||
for (int i = 0; i < 1500; i++)
|
||||
{
|
||||
metrics.RecordOperation("TestOp", TimeSpan.FromMilliseconds(i), true);
|
||||
}
|
||||
|
||||
var stats = metrics.GetStatistics();
|
||||
// TotalCount tracks all 1500 but percentile is computed from the last 1000
|
||||
stats["TestOp"].TotalCount.Should().Be(1500);
|
||||
// The rolling buffer should have entries from 500-1499
|
||||
// P95 of 500..1499 should be around 1449
|
||||
stats["TestOp"].Percentile95Milliseconds.Should().BeGreaterThan(1000);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BeginOperation_RecordsDurationOnDispose()
|
||||
{
|
||||
using var metrics = new PerformanceMetrics();
|
||||
|
||||
using (var scope = metrics.BeginOperation("TestOp"))
|
||||
{
|
||||
System.Threading.Thread.Sleep(50);
|
||||
}
|
||||
|
||||
var stats = metrics.GetStatistics();
|
||||
stats.Should().ContainKey("TestOp");
|
||||
stats["TestOp"].TotalCount.Should().Be(1);
|
||||
stats["TestOp"].AverageMilliseconds.Should().BeGreaterOrEqualTo(40);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TimingScope_DefaultsToSuccess()
|
||||
{
|
||||
using var metrics = new PerformanceMetrics();
|
||||
|
||||
using (metrics.BeginOperation("TestOp"))
|
||||
{
|
||||
// Do nothing — default is success
|
||||
}
|
||||
|
||||
var stats = metrics.GetStatistics();
|
||||
stats["TestOp"].SuccessCount.Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TimingScope_RespectsSetSuccessFalse()
|
||||
{
|
||||
using var metrics = new PerformanceMetrics();
|
||||
|
||||
using (var scope = metrics.BeginOperation("TestOp"))
|
||||
{
|
||||
scope.SetSuccess(false);
|
||||
}
|
||||
|
||||
var stats = metrics.GetStatistics();
|
||||
stats["TestOp"].SuccessCount.Should().Be(0);
|
||||
stats["TestOp"].TotalCount.Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMetrics_ReturnsNullForUnknownOperation()
|
||||
{
|
||||
using var metrics = new PerformanceMetrics();
|
||||
|
||||
var result = metrics.GetMetrics("DoesNotExist");
|
||||
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetAllMetrics_ReturnsAllTrackedOperations()
|
||||
{
|
||||
using var metrics = new PerformanceMetrics();
|
||||
|
||||
metrics.RecordOperation("Read", TimeSpan.FromMilliseconds(10), true);
|
||||
metrics.RecordOperation("Write", TimeSpan.FromMilliseconds(20), true);
|
||||
metrics.RecordOperation("Subscribe", TimeSpan.FromMilliseconds(5), true);
|
||||
|
||||
var all = metrics.GetAllMetrics();
|
||||
all.Should().ContainKey("Read");
|
||||
all.Should().ContainKey("Write");
|
||||
all.Should().ContainKey("Subscribe");
|
||||
all.Count.Should().Be(3);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.LmxProxy.Host.Domain;
|
||||
|
||||
namespace ZB.MOM.WW.LmxProxy.Host.Tests.MxAccess
|
||||
{
|
||||
public class TypedValueEqualsTests
|
||||
{
|
||||
[Fact]
|
||||
public void NullEqualsNull() => TypedValueComparer.Equals(null, null).Should().BeTrue();
|
||||
|
||||
[Fact]
|
||||
public void NullNotEqualsValue() => TypedValueComparer.Equals(null, 42).Should().BeFalse();
|
||||
|
||||
[Fact]
|
||||
public void ValueNotEqualsNull() => TypedValueComparer.Equals(42, null).Should().BeFalse();
|
||||
|
||||
[Fact]
|
||||
public void SameTypeAndValue() => TypedValueComparer.Equals(42.5, 42.5).Should().BeTrue();
|
||||
|
||||
[Fact]
|
||||
public void SameTypeDifferentValue() => TypedValueComparer.Equals(42.5, 43.0).Should().BeFalse();
|
||||
|
||||
[Fact]
|
||||
public void DifferentTypes_NeverEqual() => TypedValueComparer.Equals(1, 1.0).Should().BeFalse();
|
||||
|
||||
[Fact]
|
||||
public void BoolTrue() => TypedValueComparer.Equals(true, true).Should().BeTrue();
|
||||
|
||||
[Fact]
|
||||
public void BoolFalse() => TypedValueComparer.Equals(false, true).Should().BeFalse();
|
||||
|
||||
[Fact]
|
||||
public void String_CaseSensitive()
|
||||
{
|
||||
TypedValueComparer.Equals("DONE", "DONE").Should().BeTrue();
|
||||
TypedValueComparer.Equals("done", "DONE").Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Array_SameElements()
|
||||
{
|
||||
TypedValueComparer.Equals(new[] { 1, 2, 3 }, new[] { 1, 2, 3 }).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Array_DifferentElements()
|
||||
{
|
||||
TypedValueComparer.Equals(new[] { 1, 2, 3 }, new[] { 1, 2, 4 }).Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Array_DifferentLengths()
|
||||
{
|
||||
TypedValueComparer.Equals(new[] { 1, 2 }, new[] { 1, 2, 3 }).Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Int32_NotEqual_ToDouble()
|
||||
{
|
||||
TypedValueComparer.Equals(1, 1.0).Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Long_Equality()
|
||||
{
|
||||
TypedValueComparer.Equals(long.MaxValue, long.MaxValue).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DateTime_TickPrecision()
|
||||
{
|
||||
var dt1 = new System.DateTime(638789000000000000, System.DateTimeKind.Utc);
|
||||
var dt2 = new System.DateTime(638789000000000000, System.DateTimeKind.Utc);
|
||||
TypedValueComparer.Equals(dt1, dt2).Should().BeTrue();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.LmxProxy.Host.Tests.Security
|
||||
{
|
||||
public class ApiKeyInterceptorTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("/scada.ScadaService/Write")]
|
||||
[InlineData("/scada.ScadaService/WriteBatch")]
|
||||
[InlineData("/scada.ScadaService/WriteBatchAndWait")]
|
||||
public void WriteProtectedMethods_AreCorrectlyDefined(string method)
|
||||
{
|
||||
// This test verifies the set of write-protected methods is correct.
|
||||
// The actual interceptor logic is tested via integration tests.
|
||||
var writeProtected = new System.Collections.Generic.HashSet<string>(
|
||||
System.StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"/scada.ScadaService/Write",
|
||||
"/scada.ScadaService/WriteBatch",
|
||||
"/scada.ScadaService/WriteBatchAndWait"
|
||||
};
|
||||
writeProtected.Should().Contain(method);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("/scada.ScadaService/Connect")]
|
||||
[InlineData("/scada.ScadaService/Disconnect")]
|
||||
[InlineData("/scada.ScadaService/GetConnectionState")]
|
||||
[InlineData("/scada.ScadaService/Read")]
|
||||
[InlineData("/scada.ScadaService/ReadBatch")]
|
||||
[InlineData("/scada.ScadaService/Subscribe")]
|
||||
[InlineData("/scada.ScadaService/CheckApiKey")]
|
||||
public void ReadMethods_AreNotWriteProtected(string method)
|
||||
{
|
||||
var writeProtected = new System.Collections.Generic.HashSet<string>(
|
||||
System.StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"/scada.ScadaService/Write",
|
||||
"/scada.ScadaService/WriteBatch",
|
||||
"/scada.ScadaService/WriteBatchAndWait"
|
||||
};
|
||||
writeProtected.Should().NotContain(method);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using FluentAssertions;
|
||||
using Newtonsoft.Json;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.LmxProxy.Host.Security;
|
||||
|
||||
namespace ZB.MOM.WW.LmxProxy.Host.Tests.Security
|
||||
{
|
||||
public class ApiKeyServiceTests : IDisposable
|
||||
{
|
||||
private readonly string _tempDir;
|
||||
|
||||
public ApiKeyServiceTests()
|
||||
{
|
||||
_tempDir = Path.Combine(Path.GetTempPath(), "lmxproxy-test-" + Guid.NewGuid().ToString("N").Substring(0, 8));
|
||||
Directory.CreateDirectory(_tempDir);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_tempDir))
|
||||
Directory.Delete(_tempDir, true);
|
||||
}
|
||||
|
||||
private string CreateKeyFile(params ApiKey[] keys)
|
||||
{
|
||||
var path = Path.Combine(_tempDir, "apikeys.json");
|
||||
var config = new ApiKeyConfiguration { ApiKeys = new System.Collections.Generic.List<ApiKey>(keys) };
|
||||
File.WriteAllText(path, JsonConvert.SerializeObject(config, Formatting.Indented));
|
||||
return path;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AutoGeneratesDefaultFile_WhenMissing()
|
||||
{
|
||||
var path = Path.Combine(_tempDir, "missing.json");
|
||||
using (var svc = new ApiKeyService(path))
|
||||
{
|
||||
File.Exists(path).Should().BeTrue();
|
||||
svc.KeyCount.Should().Be(2);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateApiKey_ReturnsKey_WhenValid()
|
||||
{
|
||||
var path = CreateKeyFile(new ApiKey { Key = "test-key", Role = ApiKeyRole.ReadWrite, Enabled = true });
|
||||
using (var svc = new ApiKeyService(path))
|
||||
{
|
||||
var key = svc.ValidateApiKey("test-key");
|
||||
key.Should().NotBeNull();
|
||||
key!.Role.Should().Be(ApiKeyRole.ReadWrite);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateApiKey_ReturnsNull_WhenInvalid()
|
||||
{
|
||||
var path = CreateKeyFile(new ApiKey { Key = "test-key", Role = ApiKeyRole.ReadWrite, Enabled = true });
|
||||
using (var svc = new ApiKeyService(path))
|
||||
{
|
||||
svc.ValidateApiKey("wrong-key").Should().BeNull();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateApiKey_ReturnsNull_WhenDisabled()
|
||||
{
|
||||
var path = CreateKeyFile(new ApiKey { Key = "test-key", Role = ApiKeyRole.ReadWrite, Enabled = false });
|
||||
using (var svc = new ApiKeyService(path))
|
||||
{
|
||||
svc.ValidateApiKey("test-key").Should().BeNull();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasRole_ReadWrite_CanRead()
|
||||
{
|
||||
var path = CreateKeyFile(new ApiKey { Key = "rw", Role = ApiKeyRole.ReadWrite, Enabled = true });
|
||||
using (var svc = new ApiKeyService(path))
|
||||
{
|
||||
svc.HasRole("rw", ApiKeyRole.ReadOnly).Should().BeTrue();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasRole_ReadOnly_CannotWrite()
|
||||
{
|
||||
var path = CreateKeyFile(new ApiKey { Key = "ro", Role = ApiKeyRole.ReadOnly, Enabled = true });
|
||||
using (var svc = new ApiKeyService(path))
|
||||
{
|
||||
svc.HasRole("ro", ApiKeyRole.ReadWrite).Should().BeFalse();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasRole_ReadWrite_CanWrite()
|
||||
{
|
||||
var path = CreateKeyFile(new ApiKey { Key = "rw", Role = ApiKeyRole.ReadWrite, Enabled = true });
|
||||
using (var svc = new ApiKeyService(path))
|
||||
{
|
||||
svc.HasRole("rw", ApiKeyRole.ReadWrite).Should().BeTrue();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateApiKey_EmptyString_ReturnsNull()
|
||||
{
|
||||
var path = CreateKeyFile(new ApiKey { Key = "test", Enabled = true });
|
||||
using (var svc = new ApiKeyService(path))
|
||||
{
|
||||
svc.ValidateApiKey("").Should().BeNull();
|
||||
svc.ValidateApiKey(null!).Should().BeNull();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.LmxProxy.Host.Sessions;
|
||||
|
||||
namespace ZB.MOM.WW.LmxProxy.Host.Tests.Sessions
|
||||
{
|
||||
public class SessionManagerTests
|
||||
{
|
||||
[Fact]
|
||||
public void CreateSession_Returns32CharHexId()
|
||||
{
|
||||
using var sm = new SessionManager(inactivityTimeoutMinutes: 0);
|
||||
var id = sm.CreateSession("client1", "key1");
|
||||
id.Should().HaveLength(32);
|
||||
id.Should().MatchRegex("^[0-9a-f]{32}$");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateSession_IncrementsCount()
|
||||
{
|
||||
using var sm = new SessionManager(inactivityTimeoutMinutes: 0);
|
||||
sm.ActiveSessionCount.Should().Be(0);
|
||||
sm.CreateSession("c1", "k1");
|
||||
sm.ActiveSessionCount.Should().Be(1);
|
||||
sm.CreateSession("c2", "k2");
|
||||
sm.ActiveSessionCount.Should().Be(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateSession_ReturnsTrueForExistingSession()
|
||||
{
|
||||
using var sm = new SessionManager(inactivityTimeoutMinutes: 0);
|
||||
var id = sm.CreateSession("c1", "k1");
|
||||
sm.ValidateSession(id).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateSession_ReturnsFalseForUnknownSession()
|
||||
{
|
||||
using var sm = new SessionManager(inactivityTimeoutMinutes: 0);
|
||||
sm.ValidateSession("nonexistent").Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateSession_UpdatesLastActivity()
|
||||
{
|
||||
using var sm = new SessionManager(inactivityTimeoutMinutes: 0);
|
||||
var id = sm.CreateSession("c1", "k1");
|
||||
var session = sm.GetSession(id);
|
||||
var initialActivity = session!.LastActivity;
|
||||
|
||||
Thread.Sleep(50); // Small delay to ensure time passes
|
||||
sm.ValidateSession(id);
|
||||
|
||||
session.LastActivity.Should().BeAfter(initialActivity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TerminateSession_RemovesSession()
|
||||
{
|
||||
using var sm = new SessionManager(inactivityTimeoutMinutes: 0);
|
||||
var id = sm.CreateSession("c1", "k1");
|
||||
sm.TerminateSession(id).Should().BeTrue();
|
||||
sm.ActiveSessionCount.Should().Be(0);
|
||||
sm.ValidateSession(id).Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TerminateSession_ReturnsFalseForUnknownSession()
|
||||
{
|
||||
using var sm = new SessionManager(inactivityTimeoutMinutes: 0);
|
||||
sm.TerminateSession("nonexistent").Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetSession_ReturnsNullForUnknown()
|
||||
{
|
||||
using var sm = new SessionManager(inactivityTimeoutMinutes: 0);
|
||||
sm.GetSession("nonexistent").Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetSession_ReturnsCorrectInfo()
|
||||
{
|
||||
using var sm = new SessionManager(inactivityTimeoutMinutes: 0);
|
||||
var id = sm.CreateSession("client-abc", "key-xyz");
|
||||
var session = sm.GetSession(id);
|
||||
session.Should().NotBeNull();
|
||||
session!.ClientId.Should().Be("client-abc");
|
||||
session.ApiKey.Should().Be("key-xyz");
|
||||
session.SessionId.Should().Be(id);
|
||||
session.ConnectedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetAllSessions_ReturnsSnapshot()
|
||||
{
|
||||
using var sm = new SessionManager(inactivityTimeoutMinutes: 0);
|
||||
sm.CreateSession("c1", "k1");
|
||||
sm.CreateSession("c2", "k2");
|
||||
var all = sm.GetAllSessions();
|
||||
all.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConcurrentAccess_IsThreadSafe()
|
||||
{
|
||||
using var sm = new SessionManager(inactivityTimeoutMinutes: 0);
|
||||
var tasks = new Task[100];
|
||||
for (int i = 0; i < 100; i++)
|
||||
{
|
||||
int idx = i;
|
||||
tasks[i] = Task.Run(() =>
|
||||
{
|
||||
var id = sm.CreateSession($"client-{idx}", $"key-{idx}");
|
||||
sm.ValidateSession(id);
|
||||
if (idx % 3 == 0) sm.TerminateSession(id);
|
||||
});
|
||||
}
|
||||
await Task.WhenAll(tasks);
|
||||
|
||||
// Should have ~67 sessions remaining (100 - ~33 terminated)
|
||||
sm.ActiveSessionCount.Should().BeInRange(60, 70);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dispose_ClearsAllSessions()
|
||||
{
|
||||
var sm = new SessionManager(inactivityTimeoutMinutes: 0);
|
||||
sm.CreateSession("c1", "k1");
|
||||
sm.CreateSession("c2", "k2");
|
||||
sm.Dispose();
|
||||
sm.ActiveSessionCount.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConnectedSinceUtcTicks_ReturnsCorrectValue()
|
||||
{
|
||||
using var sm = new SessionManager(inactivityTimeoutMinutes: 0);
|
||||
var id = sm.CreateSession("c1", "k1");
|
||||
var session = sm.GetSession(id);
|
||||
session!.ConnectedSinceUtcTicks.Should().Be(session.ConnectedAt.Ticks);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FluentAssertions;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.LmxProxy.Host.Domain;
|
||||
using ZB.MOM.WW.LmxProxy.Host.Health;
|
||||
using HealthCheckService = ZB.MOM.WW.LmxProxy.Host.Health.HealthCheckService;
|
||||
using ZB.MOM.WW.LmxProxy.Host.Metrics;
|
||||
using ZB.MOM.WW.LmxProxy.Host.Status;
|
||||
using ZB.MOM.WW.LmxProxy.Host.Subscriptions;
|
||||
|
||||
namespace ZB.MOM.WW.LmxProxy.Host.Tests.Status
|
||||
{
|
||||
public class StatusReportServiceTests
|
||||
{
|
||||
private class FakeScadaClient : IScadaClient
|
||||
{
|
||||
public bool IsConnected { get; set; } = true;
|
||||
public ConnectionState ConnectionState { get; set; } = ConnectionState.Connected;
|
||||
public DateTime ConnectedSince => DateTime.UtcNow;
|
||||
public int ReconnectCount => 0;
|
||||
public event EventHandler<ConnectionStateChangedEventArgs>? ConnectionStateChanged;
|
||||
public Task ConnectAsync(CancellationToken ct = default) => Task.CompletedTask;
|
||||
public Task DisconnectAsync(CancellationToken ct = default) => Task.CompletedTask;
|
||||
public Task<Vtq> ReadAsync(string address, CancellationToken ct = default) =>
|
||||
Task.FromResult(Vtq.Good(42.0));
|
||||
public Task<IReadOnlyDictionary<string, Vtq>> ReadBatchAsync(IEnumerable<string> addresses, CancellationToken ct = default) =>
|
||||
Task.FromResult<IReadOnlyDictionary<string, Vtq>>(new Dictionary<string, Vtq>());
|
||||
public Task WriteAsync(string address, object value, CancellationToken ct = default) => Task.CompletedTask;
|
||||
public Task WriteBatchAsync(IReadOnlyDictionary<string, object> values, CancellationToken ct = default) => Task.CompletedTask;
|
||||
public Task<(bool flagReached, int elapsedMs)> WriteBatchAndWaitAsync(
|
||||
IReadOnlyDictionary<string, object> values, string flagTag, object flagValue,
|
||||
int timeoutMs, int pollIntervalMs, CancellationToken ct = default) =>
|
||||
Task.FromResult((false, 0));
|
||||
public Task UnsubscribeByAddressAsync(IEnumerable<string> addresses) => Task.CompletedTask;
|
||||
public Task<IAsyncDisposable> SubscribeAsync(IEnumerable<string> addresses, Action<string, Vtq> callback, CancellationToken ct = default) =>
|
||||
Task.FromResult<IAsyncDisposable>(new FakeHandle());
|
||||
public ValueTask DisposeAsync() => default;
|
||||
internal void FireEvent() => ConnectionStateChanged?.Invoke(this, null!);
|
||||
private class FakeHandle : IAsyncDisposable { public ValueTask DisposeAsync() => default; }
|
||||
}
|
||||
|
||||
private (StatusReportService svc, PerformanceMetrics pm, SubscriptionManager sm) CreateService(
|
||||
bool connected = true)
|
||||
{
|
||||
var client = new FakeScadaClient
|
||||
{
|
||||
IsConnected = connected,
|
||||
ConnectionState = connected ? ConnectionState.Connected : ConnectionState.Disconnected
|
||||
};
|
||||
var sm = new SubscriptionManager(client);
|
||||
var pm = new PerformanceMetrics();
|
||||
var health = new HealthCheckService(client, sm, pm);
|
||||
var svc = new StatusReportService(client, sm, pm, health);
|
||||
return (svc, pm, sm);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GenerateJsonReportAsync_ReturnsCamelCaseJson()
|
||||
{
|
||||
var (svc, pm, sm) = CreateService();
|
||||
using (pm) using (sm)
|
||||
{
|
||||
var json = await svc.GenerateJsonReportAsync();
|
||||
|
||||
json.Should().Contain("\"serviceName\"");
|
||||
json.Should().Contain("\"connection\"");
|
||||
json.Should().Contain("\"isConnected\"");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GenerateHtmlReportAsync_ContainsAutoRefresh()
|
||||
{
|
||||
var (svc, pm, sm) = CreateService();
|
||||
using (pm) using (sm)
|
||||
{
|
||||
var html = await svc.GenerateHtmlReportAsync();
|
||||
|
||||
html.Should().Contain("<meta http-equiv=\"refresh\" content=\"30\">");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IsHealthyAsync_ReturnsTrueWhenHealthy()
|
||||
{
|
||||
var (svc, pm, sm) = CreateService(connected: true);
|
||||
using (pm) using (sm)
|
||||
{
|
||||
var result = await svc.IsHealthyAsync();
|
||||
|
||||
result.Should().BeTrue();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IsHealthyAsync_ReturnsFalseWhenUnhealthy()
|
||||
{
|
||||
var (svc, pm, sm) = CreateService(connected: false);
|
||||
using (pm) using (sm)
|
||||
{
|
||||
var result = await svc.IsHealthyAsync();
|
||||
|
||||
result.Should().BeFalse();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GenerateJsonReportAsync_IncludesPerformanceMetrics()
|
||||
{
|
||||
var (svc, pm, sm) = CreateService();
|
||||
using (pm) using (sm)
|
||||
{
|
||||
pm.RecordOperation("Read", TimeSpan.FromMilliseconds(15), true);
|
||||
pm.RecordOperation("Write", TimeSpan.FromMilliseconds(25), true);
|
||||
|
||||
var json = await svc.GenerateJsonReportAsync();
|
||||
var parsed = JObject.Parse(json);
|
||||
|
||||
var operations = parsed["performance"]?["operations"];
|
||||
operations.Should().NotBeNull();
|
||||
// Newtonsoft CamelCasePropertyNamesContractResolver camelCases dictionary keys
|
||||
operations!["read"].Should().NotBeNull();
|
||||
operations!["write"].Should().NotBeNull();
|
||||
((long)operations!["read"]!["totalCount"]!).Should().Be(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Channels;
|
||||
using System.Threading.Tasks;
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.LmxProxy.Host.Domain;
|
||||
using ZB.MOM.WW.LmxProxy.Host.Subscriptions;
|
||||
|
||||
namespace ZB.MOM.WW.LmxProxy.Host.Tests.Subscriptions
|
||||
{
|
||||
public class SubscriptionManagerTests
|
||||
{
|
||||
/// <summary>Fake IScadaClient for testing (no COM dependency).</summary>
|
||||
private class FakeScadaClient : IScadaClient
|
||||
{
|
||||
public bool IsConnected => true;
|
||||
public ConnectionState ConnectionState => ConnectionState.Connected;
|
||||
public DateTime ConnectedSince => DateTime.UtcNow;
|
||||
public int ReconnectCount => 0;
|
||||
public event EventHandler<ConnectionStateChangedEventArgs>? ConnectionStateChanged;
|
||||
public Task ConnectAsync(CancellationToken ct = default) => Task.CompletedTask;
|
||||
public Task DisconnectAsync(CancellationToken ct = default) => Task.CompletedTask;
|
||||
public Task<Vtq> ReadAsync(string address, CancellationToken ct = default) =>
|
||||
Task.FromResult(Vtq.Good(42.0));
|
||||
public Task<IReadOnlyDictionary<string, Vtq>> ReadBatchAsync(IEnumerable<string> addresses, CancellationToken ct = default) =>
|
||||
Task.FromResult<IReadOnlyDictionary<string, Vtq>>(new Dictionary<string, Vtq>());
|
||||
public Task WriteAsync(string address, object value, CancellationToken ct = default) => Task.CompletedTask;
|
||||
public Task WriteBatchAsync(IReadOnlyDictionary<string, object> values, CancellationToken ct = default) => Task.CompletedTask;
|
||||
public Task<(bool flagReached, int elapsedMs)> WriteBatchAndWaitAsync(
|
||||
IReadOnlyDictionary<string, object> values, string flagTag, object flagValue,
|
||||
int timeoutMs, int pollIntervalMs, CancellationToken ct = default) =>
|
||||
Task.FromResult((false, 0));
|
||||
public Task UnsubscribeByAddressAsync(IEnumerable<string> addresses) => Task.CompletedTask;
|
||||
public Task<IAsyncDisposable> SubscribeAsync(IEnumerable<string> addresses, Action<string, Vtq> callback, CancellationToken ct = default) =>
|
||||
Task.FromResult<IAsyncDisposable>(new FakeSubscriptionHandle());
|
||||
public ValueTask DisposeAsync() => default;
|
||||
|
||||
// Suppress unused event warning
|
||||
internal void FireEvent() => ConnectionStateChanged?.Invoke(this, null!);
|
||||
|
||||
private class FakeSubscriptionHandle : IAsyncDisposable
|
||||
{
|
||||
public ValueTask DisposeAsync() => default;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Subscribe_ReturnsChannelReader()
|
||||
{
|
||||
using var sm = new SubscriptionManager(new FakeScadaClient());
|
||||
using var cts = new CancellationTokenSource();
|
||||
var (reader, subscriptionId) = await sm.SubscribeAsync("client1", new[] { "Tag1", "Tag2" }, cts.Token);
|
||||
reader.Should().NotBeNull();
|
||||
subscriptionId.Should().NotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OnTagValueChanged_FansOutToSubscribedClients()
|
||||
{
|
||||
using var sm = new SubscriptionManager(new FakeScadaClient());
|
||||
using var cts = new CancellationTokenSource();
|
||||
var (reader, _) = await sm.SubscribeAsync("client1", new[] { "Motor.Speed" }, cts.Token);
|
||||
|
||||
var vtq = Vtq.Good(42.0);
|
||||
sm.OnTagValueChanged("Motor.Speed", vtq);
|
||||
|
||||
var result = await reader.ReadAsync(cts.Token);
|
||||
result.address.Should().Be("Motor.Speed");
|
||||
result.vtq.Value.Should().Be(42.0);
|
||||
result.vtq.Quality.Should().Be(Quality.Good);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OnTagValueChanged_MultipleClients_BothReceive()
|
||||
{
|
||||
using var sm = new SubscriptionManager(new FakeScadaClient());
|
||||
using var cts = new CancellationTokenSource();
|
||||
var (reader1, _) = await sm.SubscribeAsync("client1", new[] { "Motor.Speed" }, cts.Token);
|
||||
var (reader2, _) = await sm.SubscribeAsync("client2", new[] { "Motor.Speed" }, cts.Token);
|
||||
|
||||
sm.OnTagValueChanged("Motor.Speed", Vtq.Good(99.0));
|
||||
|
||||
var r1 = await reader1.ReadAsync(cts.Token);
|
||||
var r2 = await reader2.ReadAsync(cts.Token);
|
||||
r1.vtq.Value.Should().Be(99.0);
|
||||
r2.vtq.Value.Should().Be(99.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OnTagValueChanged_NonSubscribedTag_NoDelivery()
|
||||
{
|
||||
using var sm = new SubscriptionManager(new FakeScadaClient());
|
||||
using var cts = new CancellationTokenSource();
|
||||
var (reader, _) = await sm.SubscribeAsync("client1", new[] { "Motor.Speed" }, cts.Token);
|
||||
|
||||
sm.OnTagValueChanged("Motor.Torque", Vtq.Good(10.0));
|
||||
|
||||
// Channel should be empty
|
||||
reader.TryRead(out _).Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UnsubscribeSubscription_CompletesChannel()
|
||||
{
|
||||
using var sm = new SubscriptionManager(new FakeScadaClient());
|
||||
using var cts = new CancellationTokenSource();
|
||||
var (reader, subscriptionId) = await sm.SubscribeAsync("client1", new[] { "Motor.Speed" }, cts.Token);
|
||||
|
||||
sm.UnsubscribeSubscription(subscriptionId);
|
||||
|
||||
// Channel should be completed
|
||||
reader.Completion.IsCompleted.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UnsubscribeSession_RemovesAllSubscriptions()
|
||||
{
|
||||
using var sm = new SubscriptionManager(new FakeScadaClient());
|
||||
using var cts = new CancellationTokenSource();
|
||||
await sm.SubscribeAsync("client1", new[] { "Motor.Speed" }, cts.Token);
|
||||
|
||||
sm.UnsubscribeSession("client1");
|
||||
|
||||
var stats = sm.GetStats();
|
||||
stats.TotalClients.Should().Be(0);
|
||||
stats.TotalTags.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RefCounting_LastSubscriptionUnsubscribeRemovesTag()
|
||||
{
|
||||
using var sm = new SubscriptionManager(new FakeScadaClient());
|
||||
using var cts = new CancellationTokenSource();
|
||||
var (_, subId1) = await sm.SubscribeAsync("client1", new[] { "Motor.Speed" }, cts.Token);
|
||||
var (_, subId2) = await sm.SubscribeAsync("client2", new[] { "Motor.Speed" }, cts.Token);
|
||||
|
||||
sm.GetStats().TotalTags.Should().Be(1);
|
||||
|
||||
sm.UnsubscribeSubscription(subId1);
|
||||
sm.GetStats().TotalTags.Should().Be(1); // client2 still subscribed
|
||||
|
||||
sm.UnsubscribeSubscription(subId2);
|
||||
sm.GetStats().TotalTags.Should().Be(0); // last subscription gone
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NotifyDisconnection_SendsBadQualityToAll()
|
||||
{
|
||||
using var sm = new SubscriptionManager(new FakeScadaClient());
|
||||
using var cts = new CancellationTokenSource();
|
||||
var (reader, _) = await sm.SubscribeAsync("client1", new[] { "Motor.Speed", "Motor.Torque" }, cts.Token);
|
||||
|
||||
sm.NotifyDisconnection();
|
||||
|
||||
// Should receive 2 bad quality messages
|
||||
reader.TryRead(out var r1).Should().BeTrue();
|
||||
r1.vtq.Quality.Should().Be(Quality.Bad_NotConnected);
|
||||
reader.TryRead(out var r2).Should().BeTrue();
|
||||
r2.vtq.Quality.Should().Be(Quality.Bad_NotConnected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Backpressure_DropOldest_DropsWhenFull()
|
||||
{
|
||||
using var sm = new SubscriptionManager(new FakeScadaClient(), channelCapacity: 3);
|
||||
using var cts = new CancellationTokenSource();
|
||||
var (reader, _) = await sm.SubscribeAsync("client1", new[] { "Motor.Speed" }, cts.Token);
|
||||
|
||||
// Fill the channel beyond capacity
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
sm.OnTagValueChanged("Motor.Speed", Vtq.Good((double)i));
|
||||
}
|
||||
|
||||
// Should have exactly 3 messages (capacity limit)
|
||||
int count = 0;
|
||||
while (reader.TryRead(out _)) count++;
|
||||
count.Should().Be(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetStats_ReturnsCorrectCounts()
|
||||
{
|
||||
using var sm = new SubscriptionManager(new FakeScadaClient());
|
||||
using var cts = new CancellationTokenSource();
|
||||
var (_, _) = await sm.SubscribeAsync("c1", new[] { "Tag1", "Tag2" }, cts.Token);
|
||||
var (_, _) = await sm.SubscribeAsync("c2", new[] { "Tag2", "Tag3" }, cts.Token);
|
||||
|
||||
var stats = sm.GetStats();
|
||||
stats.TotalClients.Should().Be(2);
|
||||
stats.TotalTags.Should().Be(3); // Tag1, Tag2, Tag3
|
||||
stats.ActiveSubscriptions.Should().Be(4); // c1:Tag1, c1:Tag2, c2:Tag2, c2:Tag3
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net48</TargetFramework>
|
||||
<LangVersion>9.0</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<RootNamespace>ZB.MOM.WW.LmxProxy.Host.Tests</RootNamespace>
|
||||
<PlatformTarget>x86</PlatformTarget>
|
||||
<Platforms>x86</Platforms>
|
||||
</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="6.12.2" />
|
||||
<PackageReference Include="Google.Protobuf" Version="3.29.3" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\ZB.MOM.WW.LmxProxy.Host\ZB.MOM.WW.LmxProxy.Host.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user