64e3fbe035
v2-ci / build (push) Failing after 1m43s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests) (push) Has been skipped
Adds <summary>, <param>, <typeparam>, and <inheritdoc/> tags to public members surfaced by commentchecker — resolves 5,847 of 5,869 issues (99.6%) across three /fixdocs passes.
215 lines
9.2 KiB
C#
215 lines
9.2 KiB
C#
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 };
|
|
|
|
/// <summary>Verifies SubscribeAsync on an empty list returns empty without calling the driver.</summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>Verifies SubscribeAsync with no resolver routes through the default host.</summary>
|
|
[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"]);
|
|
}
|
|
|
|
/// <summary>Verifies SubscribeAsync fans out correctly to multiple hosts based on resolver.</summary>
|
|
[Fact]
|
|
public async Task SubscribeAsync_MultiHost_FansOutByResolvedHost()
|
|
{
|
|
var driver = new FakeAlarmSource();
|
|
var resolver = new StubResolver(new Dictionary<string, string>
|
|
{
|
|
["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
|
|
}
|
|
|
|
/// <summary>Verifies AcknowledgeAsync does not retry on failure.</summary>
|
|
[Fact]
|
|
public async Task AcknowledgeAsync_DoesNotRetry_OnFailure()
|
|
{
|
|
var driver = new FakeAlarmSource { AcknowledgeShouldThrow = true };
|
|
var surface = NewSurface(driver, defaultHost: "h1");
|
|
|
|
await Should.ThrowAsync<InvalidOperationException>(() =>
|
|
surface.AcknowledgeAsync([new AlarmAcknowledgeRequest("s", "c", null)], CancellationToken.None));
|
|
|
|
driver.AcknowledgeCallCount.ShouldBe(1, "AlarmAcknowledge must not retry — decision #143");
|
|
}
|
|
|
|
/// <summary>Verifies SubscribeAsync retries on transient failures.</summary>
|
|
[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");
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task UnsubscribeAsync_Routes_Through_Same_Host_As_Subscribe()
|
|
{
|
|
var driver = new FakeAlarmSource();
|
|
var resolver = new StubResolver(new Dictionary<string, string>
|
|
{
|
|
["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)");
|
|
}
|
|
|
|
/// <summary>Verifies UnsubscribeAsync with no resolver uses the default host.</summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>Fake alarm source for testing.</summary>
|
|
private sealed class FakeAlarmSource : IAlarmSource
|
|
{
|
|
/// <summary>Gets the number of times SubscribeAlarmsAsync was called.</summary>
|
|
public int SubscribeCallCount { get; private set; }
|
|
|
|
/// <summary>Gets the number of times UnsubscribeAlarmsAsync was called.</summary>
|
|
public int UnsubscribeCallCount { get; private set; }
|
|
|
|
/// <summary>Gets the number of times AcknowledgeAsync was called.</summary>
|
|
public int AcknowledgeCallCount { get; private set; }
|
|
|
|
/// <summary>Gets or sets the number of failures before SubscribeAlarmsAsync succeeds.</summary>
|
|
public int SubscribeFailuresBeforeSuccess { get; set; }
|
|
|
|
/// <summary>Gets or sets whether AcknowledgeAsync should throw.</summary>
|
|
public bool AcknowledgeShouldThrow { get; set; }
|
|
|
|
/// <summary>Gets the source node IDs from the most recent SubscribeAlarmsAsync call.</summary>
|
|
public IReadOnlyList<string> LastSubscribedIds { get; private set; } = [];
|
|
|
|
/// <summary>Subscribes to alarms.</summary>
|
|
/// <param name="sourceNodeIds">The source node IDs to subscribe to.</param>
|
|
/// <param name="cancellationToken">Cancellation token.</param>
|
|
/// <returns>An alarm subscription handle.</returns>
|
|
public Task<IAlarmSubscriptionHandle> SubscribeAlarmsAsync(
|
|
IReadOnlyList<string> sourceNodeIds, CancellationToken cancellationToken)
|
|
{
|
|
SubscribeCallCount++;
|
|
LastSubscribedIds = sourceNodeIds;
|
|
if (SubscribeCallCount <= SubscribeFailuresBeforeSuccess)
|
|
throw new InvalidOperationException("transient");
|
|
return Task.FromResult<IAlarmSubscriptionHandle>(new StubHandle($"h-{SubscribeCallCount}"));
|
|
}
|
|
|
|
/// <summary>Unsubscribes from alarms.</summary>
|
|
/// <param name="handle">The subscription handle to unsubscribe.</param>
|
|
/// <param name="cancellationToken">Cancellation token.</param>
|
|
/// <returns>A completed task.</returns>
|
|
public Task UnsubscribeAlarmsAsync(IAlarmSubscriptionHandle handle, CancellationToken cancellationToken)
|
|
{
|
|
UnsubscribeCallCount++;
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
/// <summary>Acknowledges alarms.</summary>
|
|
/// <param name="acknowledgements">The alarm acknowledgements to process.</param>
|
|
/// <param name="cancellationToken">Cancellation token.</param>
|
|
/// <returns>A completed task.</returns>
|
|
public Task AcknowledgeAsync(
|
|
IReadOnlyList<AlarmAcknowledgeRequest> acknowledgements, CancellationToken cancellationToken)
|
|
{
|
|
AcknowledgeCallCount++;
|
|
if (AcknowledgeShouldThrow) throw new InvalidOperationException("ack boom");
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
/// <summary>Occurs when an alarm event is raised.</summary>
|
|
public event EventHandler<AlarmEventArgs>? OnAlarmEvent { add { } remove { } }
|
|
}
|
|
|
|
/// <summary>Stub alarm subscription handle for testing.</summary>
|
|
/// <param name="DiagnosticId">Diagnostic identifier for the handle.</param>
|
|
private sealed record StubHandle(string DiagnosticId) : IAlarmSubscriptionHandle;
|
|
|
|
/// <summary>Stub host resolver for testing multi-host scenarios.</summary>
|
|
/// <param name="map">The map of source node IDs to host names.</param>
|
|
private sealed class StubResolver(Dictionary<string, string> map) : IPerCallHostResolver
|
|
{
|
|
/// <summary>Resolves the host for the given full reference.</summary>
|
|
/// <param name="fullReference">The full reference to resolve.</param>
|
|
/// <returns>The resolved host name.</returns>
|
|
public string ResolveHost(string fullReference) => map[fullReference];
|
|
}
|
|
}
|