using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Resilience;
namespace ZB.MOM.WW.OtOpcUa.Core.Tests.Resilience;
[Trait("Category", "Unit")]
public sealed class AlarmSurfaceInvokerTests
{
private static readonly DriverResilienceOptions TierAOptions = new() { Tier = DriverTier.A };
/// Verifies SubscribeAsync on an empty list returns empty without calling the driver.
[Fact]
public async Task SubscribeAsync_EmptyList_ReturnsEmpty_WithoutDriverCall()
{
var driver = new FakeAlarmSource();
var surface = NewSurface(driver, defaultHost: "h");
var handles = await surface.SubscribeAsync([], CancellationToken.None);
handles.Count.ShouldBe(0);
driver.SubscribeCallCount.ShouldBe(0);
}
/// Verifies SubscribeAsync with no resolver routes through the default host.
[Fact]
public async Task SubscribeAsync_SingleHost_RoutesThroughDefaultHost()
{
var driver = new FakeAlarmSource();
var surface = NewSurface(driver, defaultHost: "h1");
var handles = await surface.SubscribeAsync(["src-1", "src-2"], CancellationToken.None);
handles.Count.ShouldBe(1);
driver.SubscribeCallCount.ShouldBe(1);
driver.LastSubscribedIds.ShouldBe(["src-1", "src-2"]);
}
/// Verifies SubscribeAsync fans out correctly to multiple hosts based on resolver.
[Fact]
public async Task SubscribeAsync_MultiHost_FansOutByResolvedHost()
{
var driver = new FakeAlarmSource();
var resolver = new StubResolver(new Dictionary
{
["src-1"] = "plc-a",
["src-2"] = "plc-b",
["src-3"] = "plc-a",
});
var surface = NewSurface(driver, defaultHost: "default-ignored", resolver: resolver);
var handles = await surface.SubscribeAsync(["src-1", "src-2", "src-3"], CancellationToken.None);
handles.Count.ShouldBe(2); // one per distinct host
driver.SubscribeCallCount.ShouldBe(2); // one driver call per host
}
/// Verifies AcknowledgeAsync does not retry on failure.
[Fact]
public async Task AcknowledgeAsync_DoesNotRetry_OnFailure()
{
var driver = new FakeAlarmSource { AcknowledgeShouldThrow = true };
var surface = NewSurface(driver, defaultHost: "h1");
await Should.ThrowAsync(() =>
surface.AcknowledgeAsync([new AlarmAcknowledgeRequest("s", "c", null)], CancellationToken.None));
driver.AcknowledgeCallCount.ShouldBe(1, "AlarmAcknowledge must not retry — decision #143");
}
/// Verifies SubscribeAsync retries on transient failures.
[Fact]
public async Task SubscribeAsync_Retries_Transient_Failures()
{
var driver = new FakeAlarmSource { SubscribeFailuresBeforeSuccess = 2 };
var surface = NewSurface(driver, defaultHost: "h1");
await surface.SubscribeAsync(["src"], CancellationToken.None);
driver.SubscribeCallCount.ShouldBe(3, "AlarmSubscribe retries by default — decision #143");
}
///
/// Core-007 regression: UnsubscribeAsync must route through the same host's resilience
/// pipeline that the subscription was created on, not always through the default host.
/// Verify by using a per-call resolver with two distinct hosts and checking which host
/// name reaches the driver's UnsubscribeAlarmsAsync.
///
[Fact]
public async Task UnsubscribeAsync_Routes_Through_Same_Host_As_Subscribe()
{
var driver = new FakeAlarmSource();
var resolver = new StubResolver(new Dictionary
{
["src-a1"] = "plc-a",
["src-a2"] = "plc-a",
["src-b1"] = "plc-b",
});
var surface = NewSurface(driver, defaultHost: "default-ignored", resolver: resolver);
var handles = await surface.SubscribeAsync(["src-a1", "src-a2", "src-b1"], CancellationToken.None);
// Two hosts were resolved — two handles, each bound to their respective host.
handles.Count.ShouldBe(2);
// Unsubscribe each; the driver must receive two unsubscribe calls.
foreach (var h in handles)
await surface.UnsubscribeAsync(h, CancellationToken.None);
driver.UnsubscribeCallCount.ShouldBe(2, "one unsubscribe per subscription handle (per host)");
}
/// Verifies UnsubscribeAsync with no resolver uses the default host.
[Fact]
public async Task UnsubscribeAsync_SingleHost_UsesDefaultHost()
{
// Without a resolver, subscribe and unsubscribe both use the default host.
var driver = new FakeAlarmSource();
var surface = NewSurface(driver, defaultHost: "h1");
var handles = await surface.SubscribeAsync(["src-1"], CancellationToken.None);
handles.Count.ShouldBe(1);
await surface.UnsubscribeAsync(handles[0], CancellationToken.None);
driver.UnsubscribeCallCount.ShouldBe(1);
}
private static AlarmSurfaceInvoker NewSurface(
IAlarmSource driver,
string defaultHost,
IPerCallHostResolver? resolver = null)
{
var builder = new DriverResiliencePipelineBuilder();
var invoker = new CapabilityInvoker(builder, "drv-1", () => TierAOptions);
return new AlarmSurfaceInvoker(invoker, driver, defaultHost, resolver);
}
/// Fake alarm source for testing.
private sealed class FakeAlarmSource : IAlarmSource
{
/// Gets the number of times SubscribeAlarmsAsync was called.
public int SubscribeCallCount { get; private set; }
/// Gets the number of times UnsubscribeAlarmsAsync was called.
public int UnsubscribeCallCount { get; private set; }
/// Gets the number of times AcknowledgeAsync was called.
public int AcknowledgeCallCount { get; private set; }
/// Gets or sets the number of failures before SubscribeAlarmsAsync succeeds.
public int SubscribeFailuresBeforeSuccess { get; set; }
/// Gets or sets whether AcknowledgeAsync should throw.
public bool AcknowledgeShouldThrow { get; set; }
/// Gets the source node IDs from the most recent SubscribeAlarmsAsync call.
public IReadOnlyList LastSubscribedIds { get; private set; } = [];
/// Subscribes to alarms.
/// The source node IDs to subscribe to.
/// Cancellation token.
/// An alarm subscription handle.
public Task SubscribeAlarmsAsync(
IReadOnlyList sourceNodeIds, CancellationToken cancellationToken)
{
SubscribeCallCount++;
LastSubscribedIds = sourceNodeIds;
if (SubscribeCallCount <= SubscribeFailuresBeforeSuccess)
throw new InvalidOperationException("transient");
return Task.FromResult(new StubHandle($"h-{SubscribeCallCount}"));
}
/// Unsubscribes from alarms.
/// The subscription handle to unsubscribe.
/// Cancellation token.
/// A completed task.
public Task UnsubscribeAlarmsAsync(IAlarmSubscriptionHandle handle, CancellationToken cancellationToken)
{
UnsubscribeCallCount++;
return Task.CompletedTask;
}
/// Acknowledges alarms.
/// The alarm acknowledgements to process.
/// Cancellation token.
/// A completed task.
public Task AcknowledgeAsync(
IReadOnlyList acknowledgements, CancellationToken cancellationToken)
{
AcknowledgeCallCount++;
if (AcknowledgeShouldThrow) throw new InvalidOperationException("ack boom");
return Task.CompletedTask;
}
/// Occurs when an alarm event is raised.
public event EventHandler? OnAlarmEvent { add { } remove { } }
}
/// Stub alarm subscription handle for testing.
/// Diagnostic identifier for the handle.
private sealed record StubHandle(string DiagnosticId) : IAlarmSubscriptionHandle;
/// Stub host resolver for testing multi-host scenarios.
/// The map of source node IDs to host names.
private sealed class StubResolver(Dictionary map) : IPerCallHostResolver
{
/// Resolves the host for the given full reference.
/// The full reference to resolve.
/// The resolved host name.
public string ResolveHost(string fullReference) => map[fullReference];
}
}