Files
riojoy/tests/RioJoy.Core.Tests/RioRuntimeTests.cs
T
CydandClaude Opus 4.8 905b9facc9 Fix SendInput crash; dim lit lamps after reset; harden the link
SendInput declared its parameter as Span<INPUT>, which cannot be marshalled
(MarshalDirectiveException) — the first keyboard/mouse output threw on the
serial read thread and killed the link. Pass a blittable INPUT[] instead;
add a regression test.

Also: defend the runtime so a faulty output sink can never tear down the
serial link (output is best-effort), and dim lit lamps on the first reply
from the board rather than in Start() — lamp commands sent in the first ms
after the DTR reset (on port open) are dropped before the board has booted.

Verified end-to-end against the partial RIO on COM1 (keypad types to the PC,
joystick feeds, comms stay up; lit MFD buttons come up dim).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 22:39:40 -05:00

160 lines
5.6 KiB
C#

using System.Diagnostics;
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<bool> 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 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<OperationCanceledException>(
() => 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;
}
}