using Fleet.Core; using Fleet.Hosting; namespace Fleet.Tests; public sealed class RestartScheduleTests { private static RestartSchedule Parse(string text) { Assert.True(RestartSchedule.TryParse(text, out RestartSchedule? schedule), text); return schedule!; } [Theory] [InlineData("04:00")] [InlineData("04:00Z")] [InlineData("04:00+08:00")] [InlineData("23:59-05:30")] [InlineData(" 00:00 ")] public void Parse_AcceptsTimeWithOptionalOffset(string text) => Parse(text); [Theory] [InlineData("")] [InlineData("4:00")] [InlineData("24:00")] [InlineData("04:60")] [InlineData("04:00+8:00")] [InlineData("04:00+15:00")] [InlineData("04:00 UTC")] [InlineData("0400")] [InlineData("04:00+08:60")] public void Parse_RejectsEverythingElse(string text) => Assert.False(RestartSchedule.TryParse(text, out _)); [Fact] public void NextAfter_IsStrictlyLater_AndRollsToTomorrow() { RestartSchedule utc = Parse("04:00"); Assert.Equal(new DateTimeOffset(2026, 10, 7, 4, 0, 0, TimeSpan.Zero), utc.NextAfter(new DateTimeOffset(2026, 10, 7, 3, 59, 59, TimeSpan.Zero))); Assert.Equal(new DateTimeOffset(2026, 10, 8, 4, 0, 0, TimeSpan.Zero), utc.NextAfter(new DateTimeOffset(2026, 10, 7, 4, 0, 0, TimeSpan.Zero))); Assert.Equal(new DateTimeOffset(2026, 10, 8, 4, 0, 0, TimeSpan.Zero), utc.NextAfter(new DateTimeOffset(2026, 10, 7, 20, 0, 0, TimeSpan.Zero))); } [Fact] public void NextAfter_UsesTheWrittenOffset() { // 04:00+08:00 就是 UTC 前一天 20:00 RestartSchedule china = Parse("04:00+08:00"); Assert.Equal(new DateTimeOffset(2026, 10, 6, 20, 0, 0, TimeSpan.Zero), china.NextAfter(new DateTimeOffset(2026, 10, 6, 19, 0, 0, TimeSpan.Zero))); Assert.Equal(new DateTimeOffset(2026, 10, 7, 20, 0, 0, TimeSpan.Zero), china.NextAfter(new DateTimeOffset(2026, 10, 6, 20, 0, 0, TimeSpan.Zero))); } [Fact] public void NextAfter_WithMinimumUptime_DoesNotRestartAgainRightAfterARestart() { RestartSchedule schedule = Parse("04:00"); // 04:00:03 重启后启动:加上最短运行时间再算,下一次是明天,而不是几秒后 var started = new DateTimeOffset(2026, 10, 7, 4, 0, 3, TimeSpan.Zero); Assert.Equal(new DateTimeOffset(2026, 10, 8, 4, 0, 0, TimeSpan.Zero), schedule.NextAfter(started + RestartSchedule.MinimumUptime)); // 03:55 启动:离 04:00 只有 5 分钟,不够最短运行时间,顺延到明天 var early = new DateTimeOffset(2026, 10, 7, 3, 55, 0, TimeSpan.Zero); Assert.Equal(new DateTimeOffset(2026, 10, 8, 4, 0, 0, TimeSpan.Zero), schedule.NextAfter(early + RestartSchedule.MinimumUptime)); } [Fact] public void Resolve_ReportsABadValueInsteadOfIgnoringIt() { Assert.Null(RestartSchedule.Resolve(null)); Assert.Null(RestartSchedule.Resolve(" ")); InvalidOperationException error = Assert.Throws(() => RestartSchedule.Resolve("tomorrow")); Assert.Contains("restart_at", error.Message); } [Fact] public async Task ServiceShell_StopsAtTheScheduledTime() { var time = new ImmediateTimeProvider(new DateTimeOffset(2026, 10, 7, 12, 0, 0, TimeSpan.Zero)); var log = new ListLog(); bool stoppedByToken = false; // 业务循环只在被取消时返回;定时重启到点以后令牌被取消,RunAsync 正常返回 await ServiceShell.RunAsync(async ct => { try { await Task.Delay(Timeout.Infinite, ct); } catch (OperationCanceledException) { stoppedByToken = true; throw; } }, log, new RestartController(Parse("04:00")), time).WaitAsync(TimeSpan.FromSeconds(10)); Assert.True(stoppedByToken); Assert.Contains(log.Lines, l => l.Contains("scheduled_restart", StringComparison.Ordinal)); Assert.Contains(log.Lines, l => l.Contains("restart scheduled", StringComparison.Ordinal)); } [Fact] public async Task ServiceShell_PicksUpAScheduleThePanelSendsLater() { var time = new ImmediateTimeProvider(new DateTimeOffset(2026, 10, 7, 12, 0, 0, TimeSpan.Zero)); var log = new ListLog(); using var controller = new RestartController(); Task shell = ServiceShell.RunAsync(ct => Task.Delay(Timeout.Infinite, ct), log, controller, time); await Task.Delay(300); Assert.False(shell.IsCompleted, "no schedule yet: the node keeps running"); Assert.True(controller.SetRemote("04:30+08:00")); await shell.WaitAsync(TimeSpan.FromSeconds(10)); Assert.Contains(log.Lines, l => l.Contains("scheduled_restart", StringComparison.Ordinal)); Assert.Contains(log.Lines, l => l.Contains("source=panel", StringComparison.Ordinal)); } [Fact] public async Task ServiceShell_ClearingTheScheduleCancelsTheRestart() { var time = new ManualTimeProvider(new DateTimeOffset(2026, 10, 7, 12, 0, 0, TimeSpan.Zero)); var log = new ListLog(); using var controller = new RestartController(); controller.SetRemote("04:30+08:00"); Task shell = ServiceShell.RunAsync(ct => Task.Delay(Timeout.Infinite, ct), log, controller, time); await Task.Delay(300); controller.SetRemote(null); // 面板关掉了 await Task.Delay(300); Assert.False(shell.IsCompleted); Assert.Contains(log.Lines, l => l.Contains("restart scheduled", StringComparison.Ordinal)); Assert.DoesNotContain(log.Lines, l => l.Contains("scheduled_restart", StringComparison.Ordinal)); // 清理:把进程停掉 Assert.True(controller.SetRemote("04:30+08:00")); // 新的计时器是异步注册的:反复推进,直到进程退出 for (int i = 0; i < 100 && !shell.IsCompleted; i++) { time.Advance(); await Task.Delay(50); } await shell.WaitAsync(TimeSpan.FromSeconds(10)); } [Fact] public void Controller_LocalSettingWinsOverThePanel_AndBadTextIsRejected() { using var controller = new RestartController(Parse("04:00")); Assert.True(controller.IsLocal); Assert.True(controller.SetRemote("05:00")); Assert.Equal(new DateTimeOffset(2026, 10, 7, 4, 0, 0, TimeSpan.Zero), controller.Current!.NextAfter(new DateTimeOffset(2026, 10, 7, 0, 0, 0, TimeSpan.Zero))); using var remoteOnly = new RestartController(); Assert.Null(remoteOnly.Current); Assert.False(remoteOnly.SetRemote("whenever")); Assert.Null(remoteOnly.Current); Assert.True(remoteOnly.SetRemote("05:00")); Assert.NotNull(remoteOnly.Current); Assert.True(remoteOnly.SetRemote(null)); Assert.Null(remoteOnly.Current); } /// 计时器马上触发:用来验证「到点」之后的行为,不用真的等。 private sealed class ImmediateTimeProvider : TimeProvider { private readonly DateTimeOffset _now; public ImmediateTimeProvider(DateTimeOffset now) => _now = now; public override DateTimeOffset GetUtcNow() => _now; public override ITimer CreateTimer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period) { // TrimHeapAsync 这类每 30 秒一次的循环也会走到这里;只让「很长」的延迟(定时重启,至少 10 分钟)马上触发 var timer = new ImmediateTimer(callback, state); if (dueTime >= RestartSchedule.MinimumUptime) { ThreadPool.QueueUserWorkItem(_ => timer.Fire()); } return timer; } } /// 计时器只在 时触发:用来验证「还没到点」时不会退出。 private sealed class ManualTimeProvider : TimeProvider { private readonly DateTimeOffset _now; private readonly List _timers = []; public ManualTimeProvider(DateTimeOffset now) => _now = now; public override DateTimeOffset GetUtcNow() => _now; public override ITimer CreateTimer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period) { var timer = new ImmediateTimer(callback, state); lock (_timers) { _timers.Add(timer); } return timer; } public void Advance() { ImmediateTimer[] due; lock (_timers) { due = [.. _timers]; _timers.Clear(); } foreach (ImmediateTimer timer in due) { timer.Fire(); } } } private sealed class ImmediateTimer : ITimer { private readonly TimerCallback _callback; private readonly object? _state; public ImmediateTimer(TimerCallback callback, object? state) { _callback = callback; _state = state; } public void Fire() => _callback(_state); public bool Change(TimeSpan dueTime, TimeSpan period) => true; public void Dispose() { } public ValueTask DisposeAsync() => ValueTask.CompletedTask; } private sealed class ListLog : INodeLog { private readonly List _lines = []; public IReadOnlyList Lines { get { lock (_lines) { return [.. _lines]; } } } public void Write(NodeLogLevel level, string message, params (string Key, object? Value)[] fields) { lock (_lines) { _lines.Add(message + " " + string.Join(' ', fields.Select(f => f.Key + "=" + f.Value))); } } } }