feat(lmxproxy): phase 2 — host core (MxAccessClient, SessionManager, SubscriptionManager)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.LmxProxy.Host.MxAccess;
|
||||
|
||||
namespace ZB.MOM.WW.LmxProxy.Host.Tests.MxAccess
|
||||
{
|
||||
public class StaDispatchThreadTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task DispatchAsync_ExecutesOnStaThread()
|
||||
{
|
||||
using var sta = new StaDispatchThread("Test-STA");
|
||||
var threadId = await sta.DispatchAsync(() => Thread.CurrentThread.ManagedThreadId);
|
||||
threadId.Should().NotBe(Thread.CurrentThread.ManagedThreadId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DispatchAsync_ReturnsResult()
|
||||
{
|
||||
using var sta = new StaDispatchThread("Test-STA");
|
||||
var result = await sta.DispatchAsync(() => 42);
|
||||
result.Should().Be(42);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DispatchAsync_PropagatesException()
|
||||
{
|
||||
using var sta = new StaDispatchThread("Test-STA");
|
||||
Func<Task> act = () => sta.DispatchAsync<int>(() => throw new InvalidOperationException("test error"));
|
||||
await act.Should().ThrowAsync<InvalidOperationException>().WithMessage("test error");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DispatchAsync_Action_Completes()
|
||||
{
|
||||
using var sta = new StaDispatchThread("Test-STA");
|
||||
int value = 0;
|
||||
await sta.DispatchAsync(() => { value = 99; });
|
||||
value.Should().Be(99);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dispose_CompletesGracefully()
|
||||
{
|
||||
var sta = new StaDispatchThread("Test-STA");
|
||||
sta.Dispose(); // Should not throw
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DispatchAfterDispose_ThrowsObjectDisposedException()
|
||||
{
|
||||
var sta = new StaDispatchThread("Test-STA");
|
||||
sta.Dispose();
|
||||
Func<Task> act = () => sta.DispatchAsync(() => 42);
|
||||
act.Should().ThrowAsync<ObjectDisposedException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MultipleDispatches_ExecuteInOrder()
|
||||
{
|
||||
using var sta = new StaDispatchThread("Test-STA");
|
||||
var results = new System.Collections.Concurrent.ConcurrentBag<int>();
|
||||
|
||||
var tasks = new Task[10];
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
int idx = i;
|
||||
tasks[i] = sta.DispatchAsync(() => { results.Add(idx); });
|
||||
}
|
||||
|
||||
await Task.WhenAll(tasks);
|
||||
results.Count.Should().Be(10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StaThread_HasStaApartmentState()
|
||||
{
|
||||
using var sta = new StaDispatchThread("Test-STA");
|
||||
var apartmentState = await sta.DispatchAsync(() => Thread.CurrentThread.GetApartmentState());
|
||||
apartmentState.Should().Be(ApartmentState.STA);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,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,193 @@
|
||||
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 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<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 void Subscribe_ReturnsChannelReader()
|
||||
{
|
||||
using var sm = new SubscriptionManager(new FakeScadaClient());
|
||||
using var cts = new CancellationTokenSource();
|
||||
var reader = sm.Subscribe("client1", new[] { "Tag1", "Tag2" }, cts.Token);
|
||||
reader.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OnTagValueChanged_FansOutToSubscribedClients()
|
||||
{
|
||||
using var sm = new SubscriptionManager(new FakeScadaClient());
|
||||
using var cts = new CancellationTokenSource();
|
||||
var reader = sm.Subscribe("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 = sm.Subscribe("client1", new[] { "Motor.Speed" }, cts.Token);
|
||||
var reader2 = sm.Subscribe("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 void OnTagValueChanged_NonSubscribedTag_NoDelivery()
|
||||
{
|
||||
using var sm = new SubscriptionManager(new FakeScadaClient());
|
||||
using var cts = new CancellationTokenSource();
|
||||
var reader = sm.Subscribe("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 void UnsubscribeClient_CompletesChannel()
|
||||
{
|
||||
using var sm = new SubscriptionManager(new FakeScadaClient());
|
||||
using var cts = new CancellationTokenSource();
|
||||
var reader = sm.Subscribe("client1", new[] { "Motor.Speed" }, cts.Token);
|
||||
|
||||
sm.UnsubscribeClient("client1");
|
||||
|
||||
// Channel should be completed
|
||||
reader.Completion.IsCompleted.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnsubscribeClient_RemovesFromTagSubscriptions()
|
||||
{
|
||||
using var sm = new SubscriptionManager(new FakeScadaClient());
|
||||
using var cts = new CancellationTokenSource();
|
||||
sm.Subscribe("client1", new[] { "Motor.Speed" }, cts.Token);
|
||||
|
||||
sm.UnsubscribeClient("client1");
|
||||
|
||||
var stats = sm.GetStats();
|
||||
stats.TotalClients.Should().Be(0);
|
||||
stats.TotalTags.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RefCounting_LastClientUnsubscribeRemovesTag()
|
||||
{
|
||||
using var sm = new SubscriptionManager(new FakeScadaClient());
|
||||
using var cts = new CancellationTokenSource();
|
||||
sm.Subscribe("client1", new[] { "Motor.Speed" }, cts.Token);
|
||||
sm.Subscribe("client2", new[] { "Motor.Speed" }, cts.Token);
|
||||
|
||||
sm.GetStats().TotalTags.Should().Be(1);
|
||||
|
||||
sm.UnsubscribeClient("client1");
|
||||
sm.GetStats().TotalTags.Should().Be(1); // client2 still subscribed
|
||||
|
||||
sm.UnsubscribeClient("client2");
|
||||
sm.GetStats().TotalTags.Should().Be(0); // last client gone
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NotifyDisconnection_SendsBadQualityToAll()
|
||||
{
|
||||
using var sm = new SubscriptionManager(new FakeScadaClient());
|
||||
using var cts = new CancellationTokenSource();
|
||||
var reader = sm.Subscribe("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 void Backpressure_DropOldest_DropsWhenFull()
|
||||
{
|
||||
using var sm = new SubscriptionManager(new FakeScadaClient(), channelCapacity: 3);
|
||||
using var cts = new CancellationTokenSource();
|
||||
var reader = sm.Subscribe("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 void GetStats_ReturnsCorrectCounts()
|
||||
{
|
||||
using var sm = new SubscriptionManager(new FakeScadaClient());
|
||||
using var cts = new CancellationTokenSource();
|
||||
sm.Subscribe("c1", new[] { "Tag1", "Tag2" }, cts.Token);
|
||||
sm.Subscribe("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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user