250 lines
9.0 KiB
C#
250 lines
9.0 KiB
C#
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Supervisor;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
|
|
|
|
[Trait("Category", "Unit")]
|
|
public sealed class BackoffTests
|
|
{
|
|
[Fact]
|
|
public void Default_sequence_is_5s_15s_60s_then_clamped()
|
|
{
|
|
var b = new Backoff();
|
|
b.Next().ShouldBe(TimeSpan.FromSeconds(5));
|
|
b.Next().ShouldBe(TimeSpan.FromSeconds(15));
|
|
b.Next().ShouldBe(TimeSpan.FromSeconds(60));
|
|
b.Next().ShouldBe(TimeSpan.FromSeconds(60));
|
|
b.Next().ShouldBe(TimeSpan.FromSeconds(60));
|
|
}
|
|
|
|
[Fact]
|
|
public void RecordStableRun_resets_the_ladder_to_the_start()
|
|
{
|
|
var b = new Backoff();
|
|
b.Next(); b.Next();
|
|
b.AttemptIndex.ShouldBe(2);
|
|
b.RecordStableRun();
|
|
b.AttemptIndex.ShouldBe(0);
|
|
b.Next().ShouldBe(TimeSpan.FromSeconds(5));
|
|
}
|
|
}
|
|
|
|
[Trait("Category", "Unit")]
|
|
public sealed class CircuitBreakerTests
|
|
{
|
|
[Fact]
|
|
public void Allows_crashes_below_threshold()
|
|
{
|
|
var b = new CircuitBreaker();
|
|
var now = DateTime.UtcNow;
|
|
b.TryRecordCrash(now, out _).ShouldBeTrue();
|
|
b.TryRecordCrash(now.AddSeconds(1), out _).ShouldBeTrue();
|
|
b.TryRecordCrash(now.AddSeconds(2), out _).ShouldBeTrue();
|
|
b.StickyAlertActive.ShouldBeFalse();
|
|
}
|
|
|
|
[Fact]
|
|
public void Opens_when_exceeding_threshold_in_window()
|
|
{
|
|
var b = new CircuitBreaker();
|
|
var now = DateTime.UtcNow;
|
|
b.TryRecordCrash(now, out _);
|
|
b.TryRecordCrash(now.AddSeconds(1), out _);
|
|
b.TryRecordCrash(now.AddSeconds(2), out _);
|
|
b.TryRecordCrash(now.AddSeconds(3), out var cooldown).ShouldBeFalse();
|
|
cooldown.ShouldBe(TimeSpan.FromHours(1));
|
|
b.StickyAlertActive.ShouldBeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public void Escalates_cooldown_after_second_open()
|
|
{
|
|
var b = new CircuitBreaker();
|
|
var t0 = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
|
// First burst — 4 crashes opens breaker with 1h cooldown.
|
|
for (var i = 0; i < 4; i++) b.TryRecordCrash(t0.AddSeconds(i), out _);
|
|
b.StickyAlertActive.ShouldBeTrue();
|
|
|
|
// Wait past cooldown. The first crash after cooldown-elapsed resets _openSinceUtc and
|
|
// bumps escalation level; the next 3 crashes then re-open with the escalated 4h cooldown.
|
|
b.TryRecordCrash(t0.AddHours(1).AddMinutes(1), out _);
|
|
var t1 = t0.AddHours(1).AddMinutes(1).AddSeconds(1);
|
|
b.TryRecordCrash(t1, out _);
|
|
b.TryRecordCrash(t1.AddSeconds(1), out _);
|
|
b.TryRecordCrash(t1.AddSeconds(2), out var cooldown).ShouldBeFalse();
|
|
cooldown.ShouldBe(TimeSpan.FromHours(4));
|
|
}
|
|
|
|
[Fact]
|
|
public void ManualReset_clears_everything()
|
|
{
|
|
var b = new CircuitBreaker();
|
|
var now = DateTime.UtcNow;
|
|
for (var i = 0; i < 5; i++) b.TryRecordCrash(now.AddSeconds(i), out _);
|
|
b.StickyAlertActive.ShouldBeTrue();
|
|
b.ManualReset();
|
|
b.StickyAlertActive.ShouldBeFalse();
|
|
b.TryRecordCrash(now.AddSeconds(10), out _).ShouldBeTrue();
|
|
}
|
|
}
|
|
|
|
[Trait("Category", "Unit")]
|
|
public sealed class HeartbeatMonitorTests
|
|
{
|
|
[Fact]
|
|
public void Three_consecutive_misses_declares_dead()
|
|
{
|
|
var m = new HeartbeatMonitor();
|
|
m.RecordMiss().ShouldBeFalse();
|
|
m.RecordMiss().ShouldBeFalse();
|
|
m.RecordMiss().ShouldBeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public void Ack_resets_the_miss_counter()
|
|
{
|
|
var m = new HeartbeatMonitor();
|
|
m.RecordMiss(); m.RecordMiss();
|
|
m.ConsecutiveMisses.ShouldBe(2);
|
|
m.RecordAck(DateTime.UtcNow);
|
|
m.ConsecutiveMisses.ShouldBe(0);
|
|
}
|
|
}
|
|
|
|
[Trait("Category", "Unit")]
|
|
public sealed class FocasHostSupervisorTests
|
|
{
|
|
private sealed class FakeLauncher : IHostProcessLauncher
|
|
{
|
|
public int LaunchAttempts { get; private set; }
|
|
public int Terminations { get; private set; }
|
|
public Queue<Func<IFocasClient>> Plan { get; } = new();
|
|
public bool IsProcessAlive { get; set; }
|
|
|
|
public Task<IFocasClient> LaunchAsync(CancellationToken ct)
|
|
{
|
|
LaunchAttempts++;
|
|
if (Plan.Count == 0) throw new InvalidOperationException("FakeLauncher plan exhausted");
|
|
var next = Plan.Dequeue()();
|
|
IsProcessAlive = true;
|
|
return Task.FromResult(next);
|
|
}
|
|
|
|
public Task TerminateAsync(CancellationToken ct)
|
|
{
|
|
Terminations++;
|
|
IsProcessAlive = false;
|
|
return Task.CompletedTask;
|
|
}
|
|
}
|
|
|
|
private sealed class StubFocasClient : IFocasClient
|
|
{
|
|
public bool IsConnected => true;
|
|
public Task ConnectAsync(FocasHostAddress address, TimeSpan timeout, CancellationToken ct) => Task.CompletedTask;
|
|
public Task<(object? value, uint status)> ReadAsync(FocasAddress a, FocasDataType t, CancellationToken ct) =>
|
|
Task.FromResult<(object?, uint)>((0, 0));
|
|
public Task<uint> WriteAsync(FocasAddress a, FocasDataType t, object? v, CancellationToken ct) => Task.FromResult(0u);
|
|
public Task<bool> ProbeAsync(CancellationToken ct) => Task.FromResult(true);
|
|
public void Dispose() { }
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetOrLaunch_returns_client_on_first_success()
|
|
{
|
|
var launcher = new FakeLauncher();
|
|
launcher.Plan.Enqueue(() => new StubFocasClient());
|
|
var supervisor = new FocasHostSupervisor(launcher);
|
|
var client = await supervisor.GetOrLaunchAsync(TestContext.Current.CancellationToken);
|
|
client.ShouldNotBeNull();
|
|
launcher.LaunchAttempts.ShouldBe(1);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetOrLaunch_retries_after_transient_failure_with_backoff()
|
|
{
|
|
var launcher = new FakeLauncher();
|
|
launcher.Plan.Enqueue(() => throw new TimeoutException("pipe not ready"));
|
|
launcher.Plan.Enqueue(() => new StubFocasClient());
|
|
|
|
var backoff = new Backoff([TimeSpan.FromMilliseconds(10), TimeSpan.FromMilliseconds(20)]);
|
|
var supervisor = new FocasHostSupervisor(launcher, backoff);
|
|
|
|
var unavailableMessages = new List<string>();
|
|
supervisor.OnUnavailable += m => unavailableMessages.Add(m);
|
|
|
|
var client = await supervisor.GetOrLaunchAsync(TestContext.Current.CancellationToken);
|
|
client.ShouldNotBeNull();
|
|
launcher.LaunchAttempts.ShouldBe(2);
|
|
unavailableMessages.Count.ShouldBe(1);
|
|
unavailableMessages[0].ShouldContain("launch-failed");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Repeated_launch_failures_open_breaker_and_surface_InvalidOperation()
|
|
{
|
|
var launcher = new FakeLauncher();
|
|
for (var i = 0; i < 10; i++)
|
|
launcher.Plan.Enqueue(() => throw new InvalidOperationException("simulated host refused"));
|
|
|
|
var supervisor = new FocasHostSupervisor(
|
|
launcher,
|
|
backoff: new Backoff([TimeSpan.FromMilliseconds(1)]),
|
|
breaker: new CircuitBreaker { CrashesAllowedPerWindow = 2, Window = TimeSpan.FromMinutes(5) });
|
|
|
|
var ex = await Should.ThrowAsync<InvalidOperationException>(async () =>
|
|
await supervisor.GetOrLaunchAsync(TestContext.Current.CancellationToken));
|
|
ex.Message.ShouldContain("circuit breaker");
|
|
supervisor.StickyAlertActive.ShouldBeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task NotifyHostDeadAsync_terminates_current_and_fans_out_unavailable()
|
|
{
|
|
var launcher = new FakeLauncher();
|
|
launcher.Plan.Enqueue(() => new StubFocasClient());
|
|
var supervisor = new FocasHostSupervisor(launcher);
|
|
|
|
var messages = new List<string>();
|
|
supervisor.OnUnavailable += m => messages.Add(m);
|
|
await supervisor.GetOrLaunchAsync(TestContext.Current.CancellationToken);
|
|
|
|
await supervisor.NotifyHostDeadAsync("heartbeat-loss", TestContext.Current.CancellationToken);
|
|
|
|
launcher.Terminations.ShouldBe(1);
|
|
messages.ShouldContain("heartbeat-loss");
|
|
supervisor.ObservedCrashes.ShouldBe(1);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AcknowledgeAndReset_clears_sticky_alert()
|
|
{
|
|
var launcher = new FakeLauncher();
|
|
for (var i = 0; i < 10; i++)
|
|
launcher.Plan.Enqueue(() => throw new InvalidOperationException("refused"));
|
|
var supervisor = new FocasHostSupervisor(
|
|
launcher,
|
|
backoff: new Backoff([TimeSpan.FromMilliseconds(1)]),
|
|
breaker: new CircuitBreaker { CrashesAllowedPerWindow = 1 });
|
|
|
|
try { await supervisor.GetOrLaunchAsync(TestContext.Current.CancellationToken); } catch { }
|
|
supervisor.StickyAlertActive.ShouldBeTrue();
|
|
|
|
supervisor.AcknowledgeAndReset();
|
|
supervisor.StickyAlertActive.ShouldBeFalse();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Dispose_terminates_host_process()
|
|
{
|
|
var launcher = new FakeLauncher();
|
|
launcher.Plan.Enqueue(() => new StubFocasClient());
|
|
var supervisor = new FocasHostSupervisor(launcher);
|
|
await supervisor.GetOrLaunchAsync(TestContext.Current.CancellationToken);
|
|
|
|
supervisor.Dispose();
|
|
launcher.Terminations.ShouldBe(1);
|
|
}
|
|
}
|