using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
namespace ZB.MOM.WW.OtOpcUa.Core.Tests;
[Trait("Category", "Unit")]
public sealed class DriverHostTests
{
private sealed class StubDriver(string id, bool failInit = false) : IDriver
{
/// Gets the driver instance identifier.
public string DriverInstanceId { get; } = id;
/// Gets the driver type name.
public string DriverType => "Stub";
/// Gets a value indicating whether the driver has been initialized.
public bool Initialized { get; private set; }
/// Gets a value indicating whether the driver has been shut down.
public bool ShutDown { get; private set; }
/// Initializes the driver asynchronously.
/// Configuration data (unused in stub).
/// The cancellation token.
public Task InitializeAsync(string _, CancellationToken ct)
{
if (failInit) throw new InvalidOperationException("boom");
Initialized = true;
return Task.CompletedTask;
}
/// Reinitializes the driver asynchronously.
/// Configuration data (unused in stub).
/// The cancellation token.
public Task ReinitializeAsync(string _, CancellationToken ct) => Task.CompletedTask;
/// Shuts down the driver asynchronously.
/// The cancellation token.
public Task ShutdownAsync(CancellationToken ct) { ShutDown = true; return Task.CompletedTask; }
/// Gets the current health status of the driver.
public DriverHealth GetHealth() =>
new(Initialized ? DriverState.Healthy : DriverState.Unknown, null, null);
/// Gets the memory footprint of the driver.
public long GetMemoryFootprint() => 0;
/// Flushes optional caches asynchronously.
/// The cancellation token.
public Task FlushOptionalCachesAsync(CancellationToken ct) => Task.CompletedTask;
}
/// Verifies that registering a driver initializes it and tracks its health.
[Fact]
public async Task Register_initializes_driver_and_tracks_health()
{
await using var host = new DriverHost();
var driver = new StubDriver("d-1");
await host.RegisterAsync(driver, "{}", CancellationToken.None);
host.RegisteredDriverIds.ShouldContain("d-1");
driver.Initialized.ShouldBeTrue();
host.GetHealth("d-1")!.State.ShouldBe(DriverState.Healthy);
}
/// Verifies that registration rethrows initialization failures but keeps the driver registered.
[Fact]
public async Task Register_rethrows_init_failure_but_keeps_driver_registered()
{
await using var host = new DriverHost();
var driver = new StubDriver("d-bad", failInit: true);
await Should.ThrowAsync(() =>
host.RegisterAsync(driver, "{}", CancellationToken.None));
host.RegisteredDriverIds.ShouldContain("d-bad");
}
/// Verifies that duplicate driver registration throws an exception.
[Fact]
public async Task Duplicate_registration_throws()
{
await using var host = new DriverHost();
await host.RegisterAsync(new StubDriver("d-1"), "{}", CancellationToken.None);
await Should.ThrowAsync(() =>
host.RegisterAsync(new StubDriver("d-1"), "{}", CancellationToken.None));
}
/// Verifies that unregistering a driver shuts it down and removes it.
[Fact]
public async Task Unregister_shuts_down_and_removes()
{
await using var host = new DriverHost();
var driver = new StubDriver("d-1");
await host.RegisterAsync(driver, "{}", CancellationToken.None);
await host.UnregisterAsync("d-1", CancellationToken.None);
host.RegisteredDriverIds.ShouldNotContain("d-1");
driver.ShutDown.ShouldBeTrue();
}
///
/// Core-004 regression — DriverHost is a library type whose async calls must use
/// ConfigureAwait(false) to match the convention used by CapabilityInvoker /
/// AlarmSurfaceInvoker. Asserts the awaited driver call does not post its
/// continuation back to a captured SynchronizationContext.
/// The driver awaits an unsettled TaskCompletionSource so it does not introduce its
/// own capture — only DriverHost's await of the returned Task can drive a post.
///
[Fact]
public async Task RegisterAsync_Does_Not_Capture_SynchronizationContext()
{
var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var driver = new TcsDriver("d-cfg-1", tcs);
var ctx = new TrackingSynchronizationContext();
// Run the DriverHost call on a dedicated thread that has our tracking SyncContext installed.
var workerCtx = await RunOnContextAsync(ctx, async () =>
{
var host = new DriverHost();
var registerTask = host.RegisterAsync(driver, "{}", CancellationToken.None);
// Complete the driver's InitializeAsync from a background thread so DriverHost's
// await must resume via the captured context if ConfigureAwait(false) was missing.
_ = Task.Run(() => tcs.SetResult());
await registerTask.ConfigureAwait(false);
await host.DisposeAsync().ConfigureAwait(false);
});
workerCtx.PostCount.ShouldBe(0,
"RegisterAsync's awaited driver call must use ConfigureAwait(false) so the continuation does not post back to the captured context");
}
/// Verifies that UnregisterAsync does not capture the synchronization context.
[Fact]
public async Task UnregisterAsync_Does_Not_Capture_SynchronizationContext()
{
var initTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
initTcs.SetResult();
var shutdownTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var driver = new TcsDriver("d-cfg-2", initTcs, shutdownTcs);
var ctx = new TrackingSynchronizationContext();
var workerCtx = await RunOnContextAsync(ctx, async () =>
{
var host = new DriverHost();
await host.RegisterAsync(driver, "{}", CancellationToken.None).ConfigureAwait(false);
// After RegisterAsync we re-enter the context. Reset the post counter so we only
// observe UnregisterAsync's behaviour from here on.
((TrackingSynchronizationContext)SynchronizationContext.Current!).Reset();
var task = host.UnregisterAsync("d-cfg-2", CancellationToken.None);
_ = Task.Run(() => shutdownTcs.SetResult());
await task.ConfigureAwait(false);
});
workerCtx.PostCount.ShouldBe(0,
"UnregisterAsync's awaited shutdown call must use ConfigureAwait(false)");
}
/// Verifies that DisposeAsync does not capture the synchronization context.
[Fact]
public async Task DisposeAsync_Does_Not_Capture_SynchronizationContext()
{
var initTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
initTcs.SetResult();
var shutdownTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var driver = new TcsDriver("d-cfg-3", initTcs, shutdownTcs);
var ctx = new TrackingSynchronizationContext();
var workerCtx = await RunOnContextAsync(ctx, async () =>
{
var host = new DriverHost();
await host.RegisterAsync(driver, "{}", CancellationToken.None).ConfigureAwait(false);
((TrackingSynchronizationContext)SynchronizationContext.Current!).Reset();
var task = host.DisposeAsync();
_ = Task.Run(() => shutdownTcs.SetResult());
await task.ConfigureAwait(false);
});
workerCtx.PostCount.ShouldBe(0,
"DisposeAsync's awaited shutdown call must use ConfigureAwait(false)");
}
///
/// Run on a dedicated thread with
/// installed as the current SynchronizationContext, and return
/// after the body completes. The dedicated thread guarantees that resuming via the
/// captured context observably routes through our Post hook (the ThreadPool would
/// otherwise clear the context on the resuming worker).
///
private static Task RunOnContextAsync(TrackingSynchronizationContext ctx, Func body)
{
var done = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var t = new Thread(() =>
{
SynchronizationContext.SetSynchronizationContext(ctx);
try
{
// Pump posted continuations until the body completes.
var task = body();
while (!task.IsCompleted)
{
if (ctx.TryDequeue(out var work)) work();
else Thread.Sleep(1);
}
// Drain any tail continuations.
while (ctx.TryDequeue(out var work)) work();
task.GetAwaiter().GetResult();
done.SetResult(ctx);
}
catch (Exception ex) { done.SetException(ex); }
}) { IsBackground = true };
t.Start();
return done.Task;
}
/// Driver whose Initialize / Shutdown completions are caller-controlled via TCS.
private sealed class TcsDriver(string id, TaskCompletionSource initTcs, TaskCompletionSource? shutdownTcs = null) : IDriver
{
/// Gets the driver instance identifier.
public string DriverInstanceId { get; } = id;
/// Gets the driver type name.
public string DriverType => "Tcs";
/// Initializes the driver asynchronously.
/// Configuration data (unused in TCS driver).
/// The cancellation token.
public Task InitializeAsync(string _, CancellationToken ct) => initTcs.Task;
/// Reinitializes the driver asynchronously.
/// Configuration data (unused in TCS driver).
/// The cancellation token.
public Task ReinitializeAsync(string _, CancellationToken ct) => Task.CompletedTask;
/// Shuts down the driver asynchronously.
/// The cancellation token.
public Task ShutdownAsync(CancellationToken ct) => (shutdownTcs ?? CompletedTcs).Task;
/// Gets the current health status of the driver.
public DriverHealth GetHealth() => new(DriverState.Healthy, null, null);
/// Gets the memory footprint of the driver.
public long GetMemoryFootprint() => 0;
/// Flushes optional caches asynchronously.
/// The cancellation token.
public Task FlushOptionalCachesAsync(CancellationToken ct) => Task.CompletedTask;
private static readonly TaskCompletionSource CompletedTcs = MakeCompleted();
private static TaskCompletionSource MakeCompleted()
{
var t = new TaskCompletionSource();
t.SetResult();
return t;
}
}
/// SynchronizationContext that queues posts to a thread-safe work list and counts them.
private sealed class TrackingSynchronizationContext : SynchronizationContext
{
private readonly System.Collections.Concurrent.ConcurrentQueue _queue = new();
public int PostCount;
public int SendCount;
/// Posts a callback to the work queue.
///
public override void Post(SendOrPostCallback d, object? state)
{
Interlocked.Increment(ref PostCount);
_queue.Enqueue(() => d(state));
}
/// Sends a callback synchronously.
///
public override void Send(SendOrPostCallback d, object? state)
{
Interlocked.Increment(ref SendCount);
d(state);
}
/// Attempts to dequeue a work item from the queue.
/// The dequeued work item if one was available.
/// True if a work item was dequeued; otherwise false.
public bool TryDequeue(out Action work) => _queue.TryDequeue(out work!);
/// Resets the post and send counts.
public void Reset() { Interlocked.Exchange(ref PostCount, 0); Interlocked.Exchange(ref SendCount, 0); }
}
}