using System; using System.Threading; using System.Threading.Tasks; using ZB.MOM.WW.MxGateway.Worker.Sta; namespace ZB.MOM.WW.MxGateway.Worker.Tests.Sta; public sealed class StaRuntimeTests { /// Verifies that InvokeAsync executes commands on the STA thread. /// A task that represents the asynchronous operation. [Fact] public async Task InvokeAsync_ExecutesCommandOnStaThread() { RecordingComApartmentInitializer initializer = new(); using StaRuntime runtime = CreateRuntime(initializer); runtime.Start(); StaCommandObservation observation = await runtime.InvokeAsync( () => new StaCommandObservation( Thread.CurrentThread.ManagedThreadId, Thread.CurrentThread.GetApartmentState())); Assert.Equal(runtime.StaThreadId, observation.ThreadId); Assert.Equal(initializer.InitializeThreadId, observation.ThreadId); Assert.Equal(ApartmentState.STA, observation.ApartmentState); } /// /// Verifies that InvokeAsync wakes the idle pump when a command is queued. /// The pump is configured with a 30-second idle period — far longer than /// any reasonable test run — so the awaited command completing at all proves /// the command wake event (not the idle pump tick) drove the dispatch. No /// wall-clock assertion is used: a loaded CI agent can stall an otherwise /// correct dispatch past an arbitrary millisecond budget, which would be a /// false failure. /// /// A task that represents the asynchronous operation. [Fact] public async Task InvokeAsync_WakesIdlePumpForQueuedCommand() { RecordingComApartmentInitializer initializer = new(); using StaRuntime runtime = new( initializer, new StaMessagePump(), TimeSpan.FromSeconds(30)); runtime.Start(); int threadId = await runtime.InvokeAsync(() => Thread.CurrentThread.ManagedThreadId); Assert.Equal(runtime.StaThreadId, threadId); } /// Verifies that Shutdown stops the thread and uninitializes the COM apartment. [Fact] public void Shutdown_StopsThreadAndUninitializesComApartment() { RecordingComApartmentInitializer initializer = new(); using StaRuntime runtime = CreateRuntime(initializer); runtime.Start(); bool stopped = runtime.Shutdown(TimeSpan.FromSeconds(2)); Assert.True(stopped); Assert.False(runtime.IsRunning); Assert.Equal(1, initializer.InitializeCount); Assert.Equal(1, initializer.UninitializeCount); Assert.Equal(initializer.InitializeThreadId, initializer.UninitializeThreadId); } /// Verifies that LastActivityUtc updates while the pump is idle. [Fact] public void LastActivityUtc_UpdatesWhilePumpIsIdle() { RecordingComApartmentInitializer initializer = new(); using StaRuntime runtime = CreateRuntime(initializer); runtime.Start(); DateTimeOffset firstActivity = runtime.LastActivityUtc; bool updated = SpinWait.SpinUntil( () => runtime.LastActivityUtc > firstActivity, TimeSpan.FromSeconds(2)); Assert.True(updated); } /// /// Verifies that refreshes /// . A long synchronous STA /// command (for example ReadBulk waiting timeout_ms for /// the first OnDataChange) invokes the pump step on every wait /// iteration while it legitimately holds the STA thread; refreshing /// activity here keeps the watchdog from mistaking a busy STA for a hung /// one. The runtime is deliberately left unstarted so the only /// source of activity is the pump call under test, not the idle loop. /// [Fact] public void PumpPendingMessages_RefreshesLastActivity() { RecordingComApartmentInitializer initializer = new(); using StaRuntime runtime = CreateRuntime(initializer); DateTimeOffset before = runtime.LastActivityUtc; bool refreshed = SpinWait.SpinUntil( () => { runtime.PumpPendingMessages(); return runtime.LastActivityUtc > before; }, TimeSpan.FromSeconds(2)); Assert.True(refreshed, "PumpPendingMessages must advance LastActivityUtc."); } /// Verifies that InvokeAsync faults the returned task when a command raises an exception without stopping the runtime. /// A task that represents the asynchronous operation. [Fact] public async Task InvokeAsync_CommandException_FaultsReturnedTaskWithoutStoppingRuntime() { RecordingComApartmentInitializer initializer = new(); using StaRuntime runtime = CreateRuntime(initializer); runtime.Start(); InvalidOperationException exception = await Assert.ThrowsAsync( () => runtime.InvokeAsync(() => throw new InvalidOperationException("command failed"))); int threadId = await runtime.InvokeAsync(() => Thread.CurrentThread.ManagedThreadId); Assert.Equal("command failed", exception.Message); Assert.Equal(runtime.StaThreadId, threadId); } /// /// Verifies that InvokeAsync returns a faulted task when called after /// Shutdown. is /// a dedicated subtype of so /// callers — notably MxAccessStaSession.RunAlarmPollLoopAsync — /// can distinguish the graceful shutdown signal from a vanilla /// such as an STA-affinity /// assertion. The test pins the exact type so a regression that /// reverts to a plain InvalidOperationException fails here. /// /// A task that represents the asynchronous operation. [Fact] public async Task InvokeAsync_AfterShutdown_ReturnsFaultedTask() { RecordingComApartmentInitializer initializer = new(); using StaRuntime runtime = CreateRuntime(initializer); runtime.Start(); runtime.Shutdown(TimeSpan.FromSeconds(2)); StaRuntimeShutdownException exception = await Assert.ThrowsAsync( () => runtime.InvokeAsync(() => Thread.CurrentThread.ManagedThreadId)); Assert.Contains("shutting down", exception.Message); } private static StaRuntime CreateRuntime(RecordingComApartmentInitializer initializer) { return new StaRuntime( initializer, new StaMessagePump(), TimeSpan.FromMilliseconds(25)); } /// Records the thread ID and apartment state of an STA command execution. private sealed class StaCommandObservation { /// Initializes a new instance of the StaCommandObservation class. /// Managed thread ID where the command executed. /// COM apartment state of the thread. public StaCommandObservation(int threadId, ApartmentState apartmentState) { ThreadId = threadId; ApartmentState = apartmentState; } /// The thread ID where the command executed. public int ThreadId { get; } /// The apartment state of the thread. public ApartmentState ApartmentState { get; } } private sealed class RecordingComApartmentInitializer : IStaComApartmentInitializer { /// The number of times Initialize was called. public int InitializeCount { get; private set; } /// The number of times Uninitialize was called. public int UninitializeCount { get; private set; } /// The thread ID where Initialize was called. public int? InitializeThreadId { get; private set; } /// The thread ID where Uninitialize was called. public int? UninitializeThreadId { get; private set; } /// public void Initialize() { InitializeCount++; InitializeThreadId = Thread.CurrentThread.ManagedThreadId; } /// public void Uninitialize() { UninitializeCount++; UninitializeThreadId = Thread.CurrentThread.ManagedThreadId; } } }