Key subscriptions by unique subscriptionId instead of sessionId to prevent overwrites when the same session calls Subscribe multiple times (e.g. DCL StaleTagMonitor). Add session-to-subscription reverse lookup for cleanup. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
198 lines
8.6 KiB
C#
198 lines
8.6 KiB
C#
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
|
|
}
|
|
}
|
|
}
|