using RioJoy.Core.Feedback; using RioJoy.Core.Mapping; using RioJoy.Core.Plasma; using RioJoy.Core.Protocol; using RioJoy.Core.Tests.Mapping; using RioJoy.Core.Tests.Serial; using Xunit; namespace RioJoy.Core.Tests.Feedback; public class FeedbackRouterTests : IDisposable { private readonly RecordingSink _sink = new(); private readonly CoalescingLampScheduler _scheduler; private readonly CancellationTokenSource _cts = new(); private readonly Task _pump; public FeedbackRouterTests() { _scheduler = new CoalescingLampScheduler(_sink, TimeSpan.FromMilliseconds(1)); _pump = _scheduler.RunAsync(_cts.Token); } public void Dispose() { _cts.Cancel(); _pump.Wait(TimeSpan.FromSeconds(5)); _cts.Dispose(); } private static FeedbackCommand Lamp(int address, byte state) => new() { Kind = FeedbackCommandKind.Lamp, Address = address, LampState = state }; private static FeedbackCommand Text(string text) => new() { Kind = FeedbackCommandKind.PlasmaText, Text = text }; private static FeedbackCommand Row(byte y) { var data = new byte[16]; data[0] = y; // distinguishable payload per row return new FeedbackCommand { Kind = FeedbackCommandKind.PlasmaRow, Y = y, Data = data }; } [Fact] public async Task Lamp_ProfileOwnedAddressDropped_UnownedApplied() { var map = new RioInputMap(); map[0x10] = RioMapEntry.Create(RioRouteKind.Keyboard, 0x41, lit: true); // InputRouter owns this lamp var router = new FeedbackRouter(); var logged = new List(); router.Logged += logged.Add; router.Attach(_scheduler, map, plasma: null, new ProfileFeedbackConfig()); router.Dispatch(Lamp(0x11, RioLampState.SolidBright)); // unowned → applied await FeedbackWait.For(() => _sink.Snapshot().Length >= 1); Assert.Equal("Lamp(0x11,0x3C)", Assert.Single(_sink.Snapshot())); router.Dispatch(Lamp(0x10, RioLampState.SolidBright)); // owned → dropped router.Dispatch(Lamp(0x10, RioLampState.SolidOff)); await Task.Delay(50); Assert.Single(_sink.Snapshot()); Assert.Equal(2, router.DroppedCommands); Assert.Single(logged); // logged once per address per attach, not per drop } [Fact] public async Task Detached_CommandsDroppedAndCounted() { var router = new FeedbackRouter(); router.Dispatch(Lamp(0x01, RioLampState.SolidBright)); // never attached Assert.Equal(1, router.DroppedCommands); router.Attach(_scheduler, new RioInputMap(), null, new ProfileFeedbackConfig()); router.Detach(); router.Dispatch(Lamp(0x01, RioLampState.SolidBright)); Assert.Equal(2, router.DroppedCommands); await Task.Delay(50); Assert.Empty(_sink.Snapshot()); } [Fact] public async Task AllowFlags_GateLampAndPlasma() { var transport = new FakeTransport(); var router = new FeedbackRouter(); router.Attach(_scheduler, new RioInputMap(), new PlasmaDisplay(transport), new ProfileFeedbackConfig { AllowLampCommands = false, AllowPlasmaText = false }); router.Dispatch(Lamp(0x01, RioLampState.SolidBright)); router.Dispatch(Text("NOPE")); await Task.Delay(50); Assert.Empty(_sink.Snapshot()); Assert.False(transport.Writes.TryRead(out _)); Assert.Equal(2, router.DroppedCommands); } [Fact] public async Task LampAll_SkipsProfileOwnedLamps() { var map = new RioInputMap(); map[0x00] = RioMapEntry.Create(RioRouteKind.Joystick, 1, lit: true); var router = new FeedbackRouter(); router.Attach(_scheduler, map, null, new ProfileFeedbackConfig()); router.Dispatch(new FeedbackCommand { Kind = FeedbackCommandKind.LampAll, LampState = RioLampState.SolidDim, }); int expected = Enumerable.Range(0, RioAddress.TableSize).Count(RioAddress.IsValid) - 1; await FeedbackWait.For(() => _sink.Snapshot().Length >= expected); await Task.Delay(50); string[] sent = _sink.Snapshot(); Assert.Equal(expected, sent.Length); Assert.DoesNotContain("Lamp(0x00,0x14)", sent); // the profile-owned lamp is untouched } [Fact] public async Task Plasma_FloodCoalesces_FirstAndLatestOnly() { // Park the first text mid-write; everything dispatched meanwhile collapses // to the single latest pending command. var transport = new GatedTransport(); var router = new FeedbackRouter(); router.Attach(_scheduler, new RioInputMap(), new PlasmaDisplay(transport), new ProfileFeedbackConfig()); router.Dispatch(Text("FIRST")); // goes busy, parked on the gate router.Dispatch(Text("MID-1")); // pending router.Dispatch(Text("MID-2")); // supersedes MID-1 router.Dispatch(Text("LAST")); // supersedes MID-2 transport.Open(); var writes = new List(); for (int i = 0; i < 10; i++) writes.Add(await transport.NextWriteAsync()); await Task.Delay(50); Assert.Equal(PlasmaCommands.Text("FIRST"), writes[4]); // FIRST's text chunk Assert.Equal(PlasmaCommands.Text("LAST"), writes[9]); // then only LAST's Assert.True(transport.NoMoreWrites); Assert.Equal(2, router.DroppedCommands); // the two superseded middles } [Fact] public async Task PlasmaRows_StreamInFifoOrder_NotCoalesced() { // A bitmap frame is many rows — unlike text, rows must all land, in order. var transport = new GatedTransport(); var router = new FeedbackRouter(); router.Attach(_scheduler, new RioInputMap(), new PlasmaDisplay(transport), new ProfileFeedbackConfig()); router.Dispatch(Row(0)); // goes busy, parked on the gate router.Dispatch(Row(1)); router.Dispatch(Row(2)); transport.Open(); Assert.Equal(PlasmaCommands.GraphicsRow(0, Row(0).Data!), await transport.NextWriteAsync()); Assert.Equal(PlasmaCommands.GraphicsRow(1, Row(1).Data!), await transport.NextWriteAsync()); Assert.Equal(PlasmaCommands.GraphicsRow(2, Row(2).Data!), await transport.NextWriteAsync()); Assert.Equal(0, router.DroppedCommands); } [Fact] public async Task PlasmaClear_FlushesQueuedRowsAndTexts() { var transport = new GatedTransport(); var router = new FeedbackRouter(); router.Attach(_scheduler, new RioInputMap(), new PlasmaDisplay(transport), new ProfileFeedbackConfig()); router.Dispatch(Row(0)); // in flight, parked router.Dispatch(Row(1)); // queued… router.Dispatch(Text("STALE")); router.Dispatch(new FeedbackCommand { Kind = FeedbackCommandKind.PlasmaClear }); // …flushed transport.Open(); Assert.Equal(PlasmaCommands.GraphicsRow(0, Row(0).Data!), await transport.NextWriteAsync()); Assert.Equal(PlasmaCommands.Clear(), await transport.NextWriteAsync()); await Task.Delay(50); Assert.True(transport.NoMoreWrites); Assert.Equal(2, router.DroppedCommands); // the flushed row + text } [Fact] public async Task PlasmaRowQueue_IsBounded() { var transport = new GatedTransport(); var router = new FeedbackRouter(); router.Attach(_scheduler, new RioInputMap(), new PlasmaDisplay(transport), new ProfileFeedbackConfig()); router.Dispatch(Row(0)); // parked in flight; everything below queues for (int i = 0; i < 140; i++) router.Dispatch(Row(1)); Assert.Equal(140 - 128, router.DroppedCommands); // over the 128-entry bound transport.Open(); // Drain: the parked row + the 128 queued ones. for (int i = 0; i < 129; i++) await transport.NextWriteAsync(); await Task.Delay(50); Assert.True(transport.NoMoreWrites); } [Fact] public async Task PlasmaClear_WritesTheClearCommand() { var transport = new FakeTransport(); var router = new FeedbackRouter(); router.Attach(_scheduler, new RioInputMap(), new PlasmaDisplay(transport), new ProfileFeedbackConfig()); router.Dispatch(new FeedbackCommand { Kind = FeedbackCommandKind.PlasmaClear }); Assert.Equal(PlasmaCommands.Clear(), await transport.NextWriteAsync()); } }