using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.AdminUI.LiveTail; namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.LiveTail; /// /// R04 P7: the live-tail pages coalesce event bursts into one render per window and keep their rows /// in a bounded ring. All timing here is driven by a manual , so nothing /// depends on wall-clock scheduling. /// [Trait("Category", "Unit")] public sealed class LiveTailTests { [Fact] public void Buffer_items_are_newest_first_and_only_change_on_flush() { var buffer = new LiveTailBuffer(capacity: 3); buffer.Add(1).ShouldBeTrue(); // first pending ⇒ caller schedules buffer.Add(2).ShouldBeFalse(); // already pending ⇒ no second schedule buffer.Items.ShouldBeEmpty(); // not visible until flush buffer.Flush().ShouldBeTrue(); buffer.Items.ShouldBe([2, 1]); buffer.Flush().ShouldBeFalse(); // nothing queued ⇒ no render worth doing } [Fact] public void Buffer_evicts_the_oldest_past_capacity() { var buffer = new LiveTailBuffer(capacity: 3); for (var i = 1; i <= 5; i++) buffer.Add(i); buffer.Flush(); buffer.Items.ShouldBe([5, 4, 3]); buffer.Add(6); buffer.Flush(); buffer.Items.ShouldBe([6, 5, 4]); } [Fact] public void Buffer_Clear_drops_retained_and_pending_items() { var buffer = new LiveTailBuffer(capacity: 3); buffer.Add(1); buffer.Flush(); buffer.Add(2); buffer.Clear(); buffer.Items.ShouldBeEmpty(); buffer.Flush().ShouldBeFalse(); // Positive control: the buffer still works after a clear, and the next add re-arms scheduling. buffer.Add(3).ShouldBeTrue(); buffer.Flush(); buffer.Items.ShouldBe([3]); } [Fact] public async Task Scheduler_runs_one_flush_per_window_for_a_burst() { var time = new ManualTimeProvider(); var flushes = 0; await using var scheduler = new CoalescingRenderScheduler( TimeSpan.FromMilliseconds(200), () => { flushes++; return Task.CompletedTask; }, time); for (var i = 0; i < 100; i++) scheduler.Schedule(); flushes.ShouldBe(0); time.Advance(TimeSpan.FromMilliseconds(199)); flushes.ShouldBe(0); time.Advance(TimeSpan.FromMilliseconds(1)); flushes.ShouldBe(1); // The window re-arms after a fire: a later burst gets exactly one more flush. scheduler.Schedule(); scheduler.Schedule(); time.Advance(TimeSpan.FromMilliseconds(200)); flushes.ShouldBe(2); } [Fact] public async Task Scheduler_does_not_flush_after_dispose() { var time = new ManualTimeProvider(); var flushes = 0; var scheduler = new CoalescingRenderScheduler( TimeSpan.FromMilliseconds(200), () => { flushes++; return Task.CompletedTask; }, time); // Positive control: the scheduler does flush before dispose. scheduler.Schedule(); time.Advance(TimeSpan.FromMilliseconds(200)); flushes.ShouldBe(1); scheduler.Schedule(); await scheduler.DisposeAsync(); time.Advance(TimeSpan.FromMilliseconds(500)); flushes.ShouldBe(1); Should.NotThrow(scheduler.Schedule); } [Fact] public async Task Scheduler_swallows_a_faulting_flush_and_keeps_working() { var time = new ManualTimeProvider(); var calls = 0; await using var scheduler = new CoalescingRenderScheduler(TimeSpan.FromMilliseconds(200), () => { calls++; if (calls == 1) throw new ObjectDisposedException("renderer"); return Task.FromException(new InvalidOperationException("late")); }, time); scheduler.Schedule(); Should.NotThrow(() => time.Advance(TimeSpan.FromMilliseconds(200))); scheduler.Schedule(); Should.NotThrow(() => time.Advance(TimeSpan.FromMilliseconds(200))); calls.ShouldBe(2); } [Fact] public void Pages_use_the_coalescing_tail_instead_of_a_render_per_event() { foreach (var page in new[] { "ScriptLog.razor", "Alerts.razor" }) { var src = AdminUiSource.Read("Components", "Pages", page); src.ShouldContain("CoalescingRenderScheduler", customMessage: page); src.ShouldContain("LiveTailBuffer<", customMessage: page); src.ShouldNotContain("_rows.Insert(0,", customMessage: page); } } /// A deterministic : timers fire only when passes their due time. private sealed class ManualTimeProvider : TimeProvider { private readonly List _timers = new(); private DateTimeOffset _now = new(2026, 10, 22, 0, 0, 0, TimeSpan.Zero); public override DateTimeOffset GetUtcNow() => _now; public override ITimer CreateTimer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period) { var timer = new ManualTimer(this, callback, state); timer.Change(dueTime, period); _timers.Add(timer); return timer; } public void Advance(TimeSpan by) { _now += by; foreach (var t in _timers.ToArray()) t.FireIfDue(_now); } private sealed class ManualTimer(ManualTimeProvider owner, TimerCallback callback, object? state) : ITimer { private DateTimeOffset? _due; private bool _disposed; public bool Change(TimeSpan dueTime, TimeSpan period) { if (_disposed) throw new ObjectDisposedException(nameof(ManualTimer)); _due = dueTime == Timeout.InfiniteTimeSpan ? null : owner._now + dueTime; return true; } public void FireIfDue(DateTimeOffset now) { if (_disposed || _due is null || _due > now) return; _due = null; callback(state); } public void Dispose() => _disposed = true; public ValueTask DisposeAsync() { _disposed = true; return ValueTask.CompletedTask; } } } }