using System.Diagnostics.CodeAnalysis; using System.Runtime.CompilerServices; using Microsoft.Extensions.Logging.Abstractions; using ZB.MOM.WW.MxGateway.Contracts.Proto; using ZB.MOM.WW.MxGateway.Server.Dashboard; using ZB.MOM.WW.MxGateway.Server.Sessions; using ZB.MOM.WW.MxGateway.Server.Workers; using ZB.MOM.WW.MxGateway.Tests.TestSupport; namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard; public sealed class DashboardLiveDataServiceTests { // Mirrors DashboardLiveDataService.MaxSubscribedTags — the cap is private, so the // tests drive it through the public read surface at exactly its documented size. private const int MaxSubscribedTags = 256; /// /// Verifies a tag already in the advise set is not subscribed again on a later read. /// [Fact] public async Task ReadAsync_WhenTagAlreadySubscribed_DoesNotResubscribe() { RecordingWorkerClient worker = new(); await using FakeSessionManager sessionManager = new(worker); await using DashboardLiveDataService service = CreateService(sessionManager); await service.ReadAsync(["Tank_001.PV", "Tank_002.PV"], CancellationToken.None); DashboardLiveReadResult second = await service.ReadAsync( ["Tank_001.PV", "Tank_002.PV"], CancellationToken.None); Assert.Null(second.Error); Assert.Equal(["Tank_001.PV", "Tank_002.PV"], worker.SubscribedTags); Assert.Empty(worker.UnsubscribedHandles); } /// /// Verifies the advise set is capped: subscribing past the cap unadvises the /// least-recently-read tag on the worker and leaves the re-read tag advised. /// [Fact] public async Task ReadAsync_PastCap_EvictsLeastRecentlyReadTag() { RecordingWorkerClient worker = new(); await using FakeSessionManager sessionManager = new(worker); await using DashboardLiveDataService service = CreateService(sessionManager); // Within one read the last tag counts as most recently read, so filler[0] is // the tail of the recency list. string[] filler = CreateTagAddresses(MaxSubscribedTags); await service.ReadAsync(filler, CancellationToken.None); // Re-read the tail, making it most recent: the tag read before it is now the // eviction candidate. await service.ReadAsync([filler[0]], CancellationToken.None); Assert.Equal(MaxSubscribedTags, worker.SubscribedTags.Count); DashboardLiveReadResult overflow = await service.ReadAsync( ["Overflow.PV"], CancellationToken.None); Assert.Null(overflow.Error); Assert.Equal([worker.HandleFor(filler[1])], worker.UnsubscribedHandles); Assert.Equal("Overflow.PV", worker.SubscribedTags[^1]); Assert.Equal(MaxSubscribedTags + 1, worker.SubscribedTags.Count); // The evicted tag is no longer tracked and re-subscribes; the re-read one does not. await service.ReadAsync([filler[0], filler[1]], CancellationToken.None); Assert.Equal(filler[1], worker.SubscribedTags[^1]); Assert.Equal(MaxSubscribedTags + 2, worker.SubscribedTags.Count); } /// /// Verifies the cap is per-read, not absolute: a single read of more distinct tags /// than the cap keeps them all (a read never evicts a tag it is about to return), /// and the next read that subscribes anything squeezes the overshoot back out. /// [Fact] public async Task ReadAsync_WithMoreDistinctTagsThanCap_KeepsThemAllThenSelfCorrects() { RecordingWorkerClient worker = new(); await using FakeSessionManager sessionManager = new(worker); await using DashboardLiveDataService service = CreateService(sessionManager); string[] oversize = CreateTagAddresses(300); DashboardLiveReadResult oversizeResult = await service.ReadAsync(oversize, CancellationToken.None); Assert.Null(oversizeResult.Error); Assert.Equal(300, oversizeResult.Values.Count); Assert.Equal(300, worker.SubscribedTags.Count); Assert.Empty(worker.UnsubscribedHandles); // 300 + 1 - 256 = 45 evicted in one pass, landing the set back on the cap. await service.ReadAsync(["Overflow.PV"], CancellationToken.None); Assert.Equal(oversize[..45].Select(worker.HandleFor), worker.UnsubscribedHandles); // Exactly at the cap now: one more new tag evicts exactly one. worker.UnsubscribedHandles.Clear(); await service.ReadAsync(["Overflow2.PV"], CancellationToken.None); Assert.Equal([worker.HandleFor(oversize[45])], worker.UnsubscribedHandles); } /// /// Verifies tags read in the same call are never evicted for each other: a read that /// touches nearly the whole advise set evicts only the untouched remainder, ends over /// the cap, and the following read trims it back. /// [Fact] public async Task ReadAsync_WhenTouchedTagsFillTheCap_EvictsOnlyUntouchedTags() { RecordingWorkerClient worker = new(); await using FakeSessionManager sessionManager = new(worker); await using DashboardLiveDataService service = CreateService(sessionManager); string[] filler = CreateTagAddresses(MaxSubscribedTags); await service.ReadAsync(filler, CancellationToken.None); // 250 already-advised tags + 10 new ones: only the 6 untouched tags are // evictable, so the set ends at 260. string[] fresh = CreateTagAddresses(10, "Fresh"); await service.ReadAsync([.. filler[..250], .. fresh], CancellationToken.None); Assert.Equal(filler[250..].Select(worker.HandleFor), worker.UnsubscribedHandles); // 260 + 1 - 256 = 5 evicted on the next read that subscribes anything. worker.UnsubscribedHandles.Clear(); await service.ReadAsync(["Overflow.PV"], CancellationToken.None); Assert.Equal(5, worker.UnsubscribedHandles.Count); } /// /// Verifies a tag the worker failed to advise still occupies a slot but is evicted /// without any unsubscribe command — there is no item handle to unadvise. /// [Fact] public async Task ReadAsync_WhenAdviseFailed_EvictsTagWithoutUnsubscribing() { RecordingWorkerClient worker = new(); worker.FailSubscribeFor.Add("Bad.PV"); await using FakeSessionManager sessionManager = new(worker); await using DashboardLiveDataService service = CreateService(sessionManager); // Bad.PV is read first, so it is the least recently read of the batch and the // first tag evicted. string[] filler = CreateTagAddresses(MaxSubscribedTags - 1); await service.ReadAsync(["Bad.PV", .. filler], CancellationToken.None); DashboardLiveReadResult overflow = await service.ReadAsync( ["Overflow.PV"], CancellationToken.None); Assert.Null(overflow.Error); Assert.Empty(worker.UnsubscribedHandles); Assert.Equal(0, worker.UnsubscribeCommandCount); // It was dropped from tracking all the same, so reading it again re-advises it. await service.ReadAsync(["Bad.PV"], CancellationToken.None); Assert.Equal("Bad.PV", worker.SubscribedTags[^1]); } /// /// Verifies a failed unadvise of an evicted tag does not fail the read, and the /// evicted tag is dropped from tracking anyway. /// [Fact] public async Task ReadAsync_WhenEvictionUnsubscribeFails_StillCompletesRead() { RecordingWorkerClient worker = new() { FailUnsubscribe = true }; await using FakeSessionManager sessionManager = new(worker); await using DashboardLiveDataService service = CreateService(sessionManager); string[] filler = CreateTagAddresses(MaxSubscribedTags); await service.ReadAsync(filler, CancellationToken.None); DashboardLiveReadResult overflow = await service.ReadAsync( ["Overflow.PV"], CancellationToken.None); Assert.Null(overflow.Error); Assert.Equal("Overflow.PV", Assert.Single(overflow.Values).TagAddress); Assert.Equal(1, sessionManager.OpenCount); // The evicted tag was dropped from tracking despite the failed unadvise. await service.ReadAsync([filler[0]], CancellationToken.None); Assert.Equal(filler[0], worker.SubscribedTags[^1]); } private static DashboardLiveDataService CreateService(ISessionManager sessionManager) { return new DashboardLiveDataService( sessionManager, new FakeGatewayAlarmService(), NullLogger.Instance); } private static string[] CreateTagAddresses(int count, string prefix = "Tank") { string[] addresses = new string[count]; for (int i = 0; i < count; i++) { addresses[i] = $"{prefix}_{i:D4}.PV"; } return addresses; } // Serves the dashboard service a single Ready session backed by the recording // worker, so reads exercise the real GatewaySession bulk command path. private sealed class FakeSessionManager(RecordingWorkerClient workerClient) : ISessionManager, IAsyncDisposable { private readonly List _sessions = []; /// Gets the number of sessions the dashboard service opened. public int OpenCount { get; private set; } /// public Task OpenSessionAsync( SessionOpenRequest request, string? clientIdentity, string? ownerKeyId, CancellationToken cancellationToken) { OpenCount++; string sessionId = $"dashboard-session-{OpenCount}"; GatewaySession session = new( sessionId, "Galaxy", $"mxgw-1-{sessionId}", "nonce", clientIdentity, request.ClientSessionName, request.ClientCorrelationId, TimeSpan.FromSeconds(30), TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(5), DateTimeOffset.UnixEpoch); session.AttachWorkerClient(workerClient); session.MarkReady(); _sessions.Add(session); return Task.FromResult(session); } /// public bool TryGetSession(string sessionId, [MaybeNullWhen(false)] out GatewaySession session) { session = _sessions.Find(candidate => candidate.SessionId == sessionId); return session is not null; } /// public Task CloseSessionAsync(string sessionId, CancellationToken cancellationToken) { return Task.FromResult(new SessionCloseResult(sessionId, SessionState.Closed, AlreadyClosed: false)); } /// public Task InvokeAsync( string sessionId, WorkerCommand command, CancellationToken cancellationToken) => throw new NotSupportedException(); /// public IAsyncEnumerable ReadEventsAsync(string sessionId, CancellationToken cancellationToken) => throw new NotSupportedException(); /// public Task KillWorkerAsync( string sessionId, string reason, CancellationToken cancellationToken) => throw new NotSupportedException(); /// public Task CloseExpiredLeasesAsync(DateTimeOffset now, CancellationToken cancellationToken) => throw new NotSupportedException(); /// public Task ShutdownAsync(CancellationToken cancellationToken) => throw new NotSupportedException(); /// Disposes every session handed to the dashboard service. /// A task that represents the asynchronous operation. public async ValueTask DisposeAsync() { foreach (GatewaySession session in _sessions) { await session.DisposeAsync().ConfigureAwait(false); } } } // Answers Register / SubscribeBulk / UnsubscribeBulk / ReadBulk with successful // replies and records what the dashboard advised and unadvised. private sealed class RecordingWorkerClient : IWorkerClient { private const int RegisteredServerHandle = 77; private readonly Dictionary _itemHandles = new(StringComparer.OrdinalIgnoreCase); private int _nextItemHandle = 1000; /// public string SessionId => "dashboard-session-1"; /// public int? ProcessId => 4242; /// public WorkerClientState State => WorkerClientState.Ready; /// public DateTimeOffset LastHeartbeatAt => DateTimeOffset.UnixEpoch; /// Gets the tag addresses subscribed, in the order the dashboard asked for them. public List SubscribedTags { get; } = []; /// Gets the item handles the dashboard unsubscribed, in order. public List UnsubscribedHandles { get; } = []; /// Gets the number of unsubscribe commands the dashboard sent. public int UnsubscribeCommandCount { get; private set; } /// Gets the tag addresses the worker refuses to advise. public HashSet FailSubscribeFor { get; } = new(StringComparer.OrdinalIgnoreCase); /// Gets or sets a value indicating whether unsubscribe commands throw. public bool FailUnsubscribe { get; set; } /// Gets the item handle bound for a previously subscribed tag. /// Tag address to look up. /// The bound item handle. public int HandleFor(string tagAddress) => _itemHandles[tagAddress]; /// public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask; /// public Task InvokeAsync( WorkerCommand command, TimeSpan timeout, CancellationToken cancellationToken) { MxCommand mxCommand = command.Command ?? throw new InvalidOperationException("The dashboard sent a command with no payload."); MxCommandReply reply = new() { Kind = mxCommand.Kind, ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok }, }; switch (mxCommand.Kind) { case MxCommandKind.Register: reply.Register = new RegisterReply { ServerHandle = RegisteredServerHandle }; break; case MxCommandKind.SubscribeBulk: reply.SubscribeBulk = Subscribe(mxCommand.SubscribeBulk.TagAddresses); break; case MxCommandKind.UnsubscribeBulk: UnsubscribeCommandCount++; if (FailUnsubscribe) { throw new InvalidOperationException("Simulated worker unsubscribe failure."); } UnsubscribedHandles.AddRange(mxCommand.UnsubscribeBulk.ItemHandles); reply.UnsubscribeBulk = new BulkSubscribeReply(); break; case MxCommandKind.ReadBulk: reply.ReadBulk = Read(mxCommand.ReadBulk.TagAddresses); break; default: throw new NotSupportedException($"Unexpected dashboard command {mxCommand.Kind}."); } return Task.FromResult(new WorkerCommandReply { Reply = reply }); } /// public async IAsyncEnumerable ReadEventsAsync( [EnumeratorCancellation] CancellationToken cancellationToken) { await Task.CompletedTask.ConfigureAwait(false); yield break; } /// public Task ShutdownAsync(TimeSpan timeout, CancellationToken cancellationToken) => Task.CompletedTask; /// public void Kill(string reason) { } /// public ValueTask DisposeAsync() => ValueTask.CompletedTask; private BulkSubscribeReply Subscribe(IEnumerable tagAddresses) { BulkSubscribeReply subscribeReply = new(); foreach (string tagAddress in tagAddresses) { SubscribedTags.Add(tagAddress); if (FailSubscribeFor.Contains(tagAddress)) { subscribeReply.Results.Add(new SubscribeResult { ServerHandle = RegisteredServerHandle, TagAddress = tagAddress, ItemHandle = 0, WasSuccessful = false, ErrorMessage = "Simulated advise failure.", }); continue; } if (!_itemHandles.TryGetValue(tagAddress, out int itemHandle)) { itemHandle = _nextItemHandle++; _itemHandles[tagAddress] = itemHandle; } subscribeReply.Results.Add(new SubscribeResult { ServerHandle = RegisteredServerHandle, TagAddress = tagAddress, ItemHandle = itemHandle, WasSuccessful = true, }); } return subscribeReply; } private BulkReadReply Read(IEnumerable tagAddresses) { BulkReadReply readReply = new(); foreach (string tagAddress in tagAddresses) { readReply.Results.Add(new BulkReadResult { ServerHandle = RegisteredServerHandle, TagAddress = tagAddress, ItemHandle = _itemHandles.TryGetValue(tagAddress, out int itemHandle) ? itemHandle : 0, WasSuccessful = true, Quality = 192, }); } return readReply; } } }