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

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

View File

@@ -0,0 +1,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"));
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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