using System.Globalization; using RioJoy.Core.Feedback; using RioJoy.Core.Mapping; using RioJoy.Core.Protocol; using RioJoy.Core.Tests.Mapping; using RioJoy.Core.Tests.Serial; using Xunit; namespace RioJoy.Core.Tests.Feedback; public class CoalescingLampSchedulerTests { private static readonly TimeSpan Fast = TimeSpan.FromMilliseconds(1); // pump as fast as timers allow private static Task WaitFor(Func condition, int timeoutMs = 5000) => FeedbackWait.For(condition, timeoutMs); // "Lamp(0x12,0x3C)" → (0x12, 0x3C) private static (int Address, byte State) ParseLamp(string entry) { string[] parts = entry["Lamp(0x".Length..^1].Split(new[] { ",0x" }, StringSplitOptions.None); return (int.Parse(parts[0], NumberStyles.HexNumber, CultureInfo.InvariantCulture), byte.Parse(parts[1], NumberStyles.HexNumber, CultureInfo.InvariantCulture)); } [Fact] public async Task Post_SameAddressRepeatedly_SendsOnlyTheLatestState() { var sink = new RecordingSink(); var scheduler = new CoalescingLampScheduler(sink, Fast); for (byte s = 0; s <= 0x30; s++) scheduler.Post(0x12, s); // a burst of updates while nothing pumps using var cts = new CancellationTokenSource(); Task pump = scheduler.RunAsync(cts.Token); await WaitFor(() => sink.Snapshot().Length >= 1); await Task.Delay(50); // give a buggy scheduler time to send the rest cts.Cancel(); await pump.WithTimeout(); string entry = Assert.Single(sink.Snapshot()); Assert.Equal((0x12, (byte)0x30), ParseLamp(entry)); // burst collapsed to the last state } [Fact] public async Task Post_UnchangedState_IsNotResent() { var sink = new RecordingSink(); var scheduler = new CoalescingLampScheduler(sink, Fast); using var cts = new CancellationTokenSource(); Task pump = scheduler.RunAsync(cts.Token); scheduler.Post(0x05, RioLampState.SolidBright); await WaitFor(() => sink.Snapshot().Length >= 1); scheduler.Post(0x05, RioLampState.SolidBright); // same state again await Task.Delay(50); cts.Cancel(); await pump.WithTimeout(); Assert.Single(sink.Snapshot()); // no resend for an unchanged lamp } [Fact] public async Task Pump_SendsAtMostOneLampPerTick() { var sink = new RecordingSink(); var scheduler = new CoalescingLampScheduler(sink, TimeSpan.FromMilliseconds(200)); scheduler.Post(0x01, RioLampState.SolidBright); scheduler.Post(0x02, RioLampState.SolidBright); scheduler.Post(0x03, RioLampState.SolidBright); using var cts = new CancellationTokenSource(); Task pump = scheduler.RunAsync(cts.Token); await WaitFor(() => sink.Snapshot().Length >= 1); // The next tick is ~200 ms out; three pending lamps must not burst. Assert.Single(sink.Snapshot()); cts.Cancel(); await pump.WithTimeout(); } [Fact] public async Task PostAll_CoversExactlyTheValidAddressSet() { var sink = new RecordingSink(); var scheduler = new CoalescingLampScheduler(sink, Fast); scheduler.PostAll(RioLampState.SolidOff); int validCount = Enumerable.Range(0, RioAddress.TableSize).Count(RioAddress.IsValid); Assert.Equal(104, validCount); // 72 buttons + 2×16 keypad keys; 0x48-0x4F is a gap using var cts = new CancellationTokenSource(); Task pump = scheduler.RunAsync(cts.Token); await WaitFor(() => sink.Snapshot().Length >= validCount); await Task.Delay(50); cts.Cancel(); await pump.WithTimeout(); var sent = sink.Snapshot().Select(ParseLamp).ToArray(); Assert.Equal(validCount, sent.Length); // nothing sent twice, no gap addresses Assert.All(sent, s => Assert.Equal(RioLampState.SolidOff, s.State)); Assert.Equal( Enumerable.Range(0, RioAddress.TableSize).Where(RioAddress.IsValid), sent.Select(s => s.Address).OrderBy(a => a)); } [Fact] public async Task Post_InvalidAddress_Ignored() { var sink = new RecordingSink(); var scheduler = new CoalescingLampScheduler(sink, Fast); scheduler.Post(0x48, RioLampState.SolidBright); // gap address (rumble config is unvalidated) scheduler.Post(0x70, RioLampState.SolidBright); scheduler.Post(-1, RioLampState.SolidBright); using var cts = new CancellationTokenSource(); Task pump = scheduler.RunAsync(cts.Token); await Task.Delay(100); cts.Cancel(); await pump.WithTimeout(); Assert.Empty(sink.Snapshot()); } [Fact] public async Task Cancel_StopsThePump() { var sink = new RecordingSink(); var scheduler = new CoalescingLampScheduler(sink, Fast); using var cts = new CancellationTokenSource(); Task pump = scheduler.RunAsync(cts.Token); cts.Cancel(); await pump.WithTimeout(); // exits cleanly, no OperationCanceledException scheduler.Post(0x01, RioLampState.SolidBright); await Task.Delay(50); Assert.Empty(sink.Snapshot()); // a stopped pump sends nothing } }