using System.Diagnostics; using AxisOutputs = RioJoy.Core.Calibration.AxisOutputs; using AxisReadout = RioJoy.Core.Calibration.AxisReadout; using RioJoy.Core; using RioJoy.Core.Mapping; using RioJoy.Core.Protocol; using RioJoy.Core.Serial; using RioJoy.Core.Tests.Mapping; using RioJoy.Core.Tests.Serial; using Xunit; namespace RioJoy.Core.Tests; public class RioRuntimeTests { private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5); private static async Task WaitUntilAsync(Func condition) { var sw = Stopwatch.StartNew(); while (!condition()) { if (sw.Elapsed > Timeout) throw new TimeoutException("Condition not met in time."); await Task.Delay(10); } } [Fact] public async Task ButtonPressed_RoutesToJoystick() { var fake = new FakeTransport(); var link = new RioSerialLink(fake, new RioSerialLinkOptions { AutoPollAnalog = false }); var recorder = new RecordingSink(); var map = new RioInputMap { [0x05] = new RioMapEntry(0x1009) }; // joystick button 9 using var runtime = new RioRuntime(link, map, recorder, recorder); runtime.Start(); using var cts = new CancellationTokenSource(); Task run = link.RunAsync(cts.Token); fake.Enqueue(PacketBuilder.Build(RioCommand.ButtonPressed, new byte[] { 0x05 })); await WaitUntilAsync(() => recorder.Snapshot().Contains("Joy(9,True)")); cts.Cancel(); await run; } [Fact] public async Task AnalogReply_DrivesAllSixAxes() { var fake = new FakeTransport(); var link = new RioSerialLink(fake, new RioSerialLinkOptions { AutoPollAnalog = false }); var recorder = new RecordingSink(); using var runtime = new RioRuntime(link, new RioInputMap(), recorder, recorder); runtime.Start(); using var cts = new CancellationTokenSource(); Task run = link.RunAsync(cts.Token); // All axes at zero → joystick X/Y centered. fake.Enqueue(PacketBuilder.Build(RioCommand.AnalogReply, new byte[10])); await WaitUntilAsync(() => recorder.Snapshot().Count(e => e.StartsWith("Axis(")) == 6); string[] log = recorder.Snapshot(); Assert.Contains("Axis(X,16383)", log); Assert.Contains("Axis(Y,16383)", log); cts.Cancel(); await run; } [Fact] public async Task AnalogReply_RaisesAxesUpdated() { var fake = new FakeTransport(); var link = new RioSerialLink(fake, new RioSerialLinkOptions { AutoPollAnalog = false }); var recorder = new RecordingSink(); using var runtime = new RioRuntime(link, new RioInputMap(), recorder, recorder); AxisReadout? seen = null; runtime.AxesUpdated += axes => seen = axes; runtime.Start(); using var cts = new CancellationTokenSource(); Task run = link.RunAsync(cts.Token); // All raw axes at zero → joystick X/Y centered, same values the sink receives; // both pedals at rest → pedal readouts empty. fake.Enqueue(PacketBuilder.Build(RioCommand.AnalogReply, new byte[10])); await WaitUntilAsync(() => seen is not null); Assert.Equal(AxisOutputs.Center, seen!.Value.Axes.X); Assert.Equal(AxisOutputs.Center, seen.Value.Axes.Y); Assert.Equal(0, seen.Value.LeftPedal); Assert.Equal(0, seen.Value.RightPedal); cts.Cancel(); await run; } [Fact] public async Task LitLamps_Dimmed_OnFirstBoardReply_NotOnStart() { var fake = new FakeTransport(); var link = new RioSerialLink(fake, new RioSerialLinkOptions { AutoPollAnalog = false }); var recorder = new RecordingSink(); var map = new RioInputMap { [0x05] = new RioMapEntry(0x9001) }; // lit joystick button using var runtime = new RioRuntime(link, map, recorder, recorder); runtime.Start(); using var cts = new CancellationTokenSource(); Task run = link.RunAsync(cts.Token); // Nothing is sent on Start: lamp commands would be dropped during the board's // post-reset boot, so no write happens until the board first replies. await Assert.ThrowsAnyAsync( () => fake.NextWriteAsync(TimeSpan.FromMilliseconds(150))); // A reply proves the board is alive → the lit lamp is dimmed. fake.Enqueue(PacketBuilder.Build(RioCommand.AnalogReply, new byte[10])); bool dimmed = false; for (int i = 0; i < 8 && !dimmed; i++) { byte[] w = await fake.NextWriteAsync(TimeSpan.FromSeconds(2)); dimmed = w.Length >= 3 && w[0] == (byte)RioCommand.LampRequest && w[1] == 0x05; } Assert.True(dimmed, "expected a LampRequest dimming address 0x05 after the first reply"); cts.Cancel(); await run; } [Fact] public async Task ButtonActivity_FiresForPressAndRelease_EvenWhenUnmapped() { var fake = new FakeTransport(); var link = new RioSerialLink(fake, new RioSerialLinkOptions { AutoPollAnalog = false }); var recorder = new RecordingSink(); using var runtime = new RioRuntime(link, new RioInputMap(), recorder, recorder); // empty map var activity = new List<(int Address, bool Pressed)>(); runtime.ButtonActivity += (addr, pressed) => activity.Add((addr, pressed)); runtime.Start(); using var cts = new CancellationTokenSource(); Task run = link.RunAsync(cts.Token); fake.Enqueue(PacketBuilder.Build(RioCommand.ButtonPressed, new byte[] { 0x05 })); fake.Enqueue(PacketBuilder.Build(RioCommand.ButtonReleased, new byte[] { 0x05 })); await WaitUntilAsync(() => activity.Count == 2); Assert.Equal((0x05, true), activity[0]); Assert.Equal((0x05, false), activity[1]); Assert.Empty(recorder.Snapshot()); // unmapped → no output routed, but activity still fired cts.Cancel(); await run; } [Fact] public async Task KeypadKey_RoutesThroughOffsetAddress() { var fake = new FakeTransport(); var link = new RioSerialLink(fake, new RioSerialLinkOptions { AutoPollAnalog = false }); var recorder = new RecordingSink(); // Keypad 1, index 2 → address 0x62; map a keyboard key there (VK 0x41 = 'A'). var map = new RioInputMap { [0x62] = new RioMapEntry(0x0041) }; using var runtime = new RioRuntime(link, map, recorder, recorder); runtime.Start(); using var cts = new CancellationTokenSource(); Task run = link.RunAsync(cts.Token); fake.Enqueue(PacketBuilder.Build(RioCommand.KeyPressed, new byte[] { 1, 2 })); await WaitUntilAsync(() => recorder.Snapshot().Contains("KeyDown(0x41,ext=False)")); cts.Cancel(); await run; } }