Port the RIO wire protocol from legacy/riovjoy2.cpp into testable C#: - Protocol/: command + length table, 7-bit checksum, packet builder, and a streaming receive-side framing state machine (PacketParser) that mirrors the legacy ReadCommBlock framing/resync (high-bit-mid-packet abort). Typed RIO->PC decodes: AnalogReport (14-bit sign-extend), VersionInfo, CheckStatus; lamp-state composition. - Serial/: RioSerialLink drives an async receive loop with ACK/NAK reply policy (legacy force-accept vs. opt-in VerifyInboundChecksum), the analog poll timer, and the >5s reset-recovery watchdog. IRioTransport abstracts the COM port; the SerialPort-backed transport does 9600 8N1 + DTR reset pulse, and acquire/release is just create/dispose (foundation for native-game serial yield). - tests/RioJoy.Core.Tests: 54 xUnit tests covering checksum, framing/resync, builder round-trips, analog sign-extension + sentinel rejection, lamp combos, and the read loop driven against an in-memory fake transport. Hardware verification (version/check/analog against a cabinet) remains; it can't be done off-device. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
145 lines
4.5 KiB
C#
145 lines
4.5 KiB
C#
using RioJoy.Core.Protocol;
|
|
using RioJoy.Core.Serial;
|
|
using Xunit;
|
|
|
|
namespace RioJoy.Core.Tests.Serial;
|
|
|
|
public class RioSerialLinkTests
|
|
{
|
|
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5);
|
|
|
|
[Fact]
|
|
public async Task AnalogReply_DecodesAndAcks()
|
|
{
|
|
var fake = new FakeTransport();
|
|
var link = new RioSerialLink(fake, new RioSerialLinkOptions { AutoPollAnalog = false });
|
|
|
|
var gotAnalog = new TaskCompletionSource<AnalogReport>(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
link.AnalogReceived += r => gotAnalog.TrySetResult(r);
|
|
|
|
using var cts = new CancellationTokenSource();
|
|
Task run = link.RunAsync(cts.Token);
|
|
|
|
byte[] frame = PacketBuilder.Build(RioCommand.AnalogReply, new byte[] { 1, 0, 2, 0, 3, 0, 4, 0, 5, 0 });
|
|
fake.Enqueue(frame);
|
|
|
|
AnalogReport report = await gotAnalog.Task.WaitAsync(Timeout);
|
|
Assert.Equal(1, report.Throttle);
|
|
Assert.Equal(5, report.JoystickX);
|
|
|
|
// The link should have ACK'd the packet.
|
|
byte[] reply = await fake.NextWriteAsync();
|
|
Assert.Equal(new byte[] { (byte)RioControl.Ack }, reply);
|
|
|
|
cts.Cancel();
|
|
await run;
|
|
}
|
|
|
|
[Fact]
|
|
public async Task BadChecksumButton_IsNakd_WhenVerificationEnabled()
|
|
{
|
|
var fake = new FakeTransport();
|
|
var link = new RioSerialLink(fake, new RioSerialLinkOptions
|
|
{
|
|
AutoPollAnalog = false,
|
|
VerifyInboundChecksum = true,
|
|
});
|
|
|
|
using var cts = new CancellationTokenSource();
|
|
Task run = link.RunAsync(cts.Token);
|
|
|
|
byte[] frame = PacketBuilder.Build(RioCommand.ButtonPressed, new byte[] { 0x05 });
|
|
frame[^1] ^= 0x01; // corrupt checksum
|
|
fake.Enqueue(frame);
|
|
|
|
byte[] reply = await fake.NextWriteAsync();
|
|
Assert.Equal(new byte[] { (byte)RioControl.Nak }, reply);
|
|
|
|
cts.Cancel();
|
|
await run;
|
|
}
|
|
|
|
[Fact]
|
|
public async Task BadChecksumButton_IsAckd_WhenVerificationDisabled()
|
|
{
|
|
var fake = new FakeTransport();
|
|
var link = new RioSerialLink(fake, new RioSerialLinkOptions { AutoPollAnalog = false });
|
|
|
|
using var cts = new CancellationTokenSource();
|
|
Task run = link.RunAsync(cts.Token);
|
|
|
|
byte[] frame = PacketBuilder.Build(RioCommand.ButtonPressed, new byte[] { 0x05 });
|
|
frame[^1] ^= 0x01;
|
|
fake.Enqueue(frame);
|
|
|
|
byte[] reply = await fake.NextWriteAsync();
|
|
Assert.Equal(new byte[] { (byte)RioControl.Ack }, reply);
|
|
|
|
cts.Cancel();
|
|
await run;
|
|
}
|
|
|
|
[Fact]
|
|
public async Task VersionReply_DecodesAndRaisesEvent()
|
|
{
|
|
var fake = new FakeTransport();
|
|
var link = new RioSerialLink(fake, new RioSerialLinkOptions { AutoPollAnalog = false });
|
|
|
|
var gotVersion = new TaskCompletionSource<VersionInfo>(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
link.VersionReceived += v => gotVersion.TrySetResult(v);
|
|
|
|
using var cts = new CancellationTokenSource();
|
|
Task run = link.RunAsync(cts.Token);
|
|
|
|
fake.Enqueue(PacketBuilder.Build(RioCommand.VersionReply, new byte[] { 0, 3 }));
|
|
|
|
VersionInfo version = await gotVersion.Task.WaitAsync(Timeout);
|
|
Assert.Equal(0, version.Major);
|
|
Assert.Equal(3, version.Minor);
|
|
|
|
cts.Cancel();
|
|
await run;
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ControlByte_RaisesControlReceived()
|
|
{
|
|
var fake = new FakeTransport();
|
|
var link = new RioSerialLink(fake, new RioSerialLinkOptions { AutoPollAnalog = false });
|
|
|
|
var gotControl = new TaskCompletionSource<byte>(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
link.ControlReceived += b => gotControl.TrySetResult(b);
|
|
|
|
using var cts = new CancellationTokenSource();
|
|
Task run = link.RunAsync(cts.Token);
|
|
|
|
fake.Enqueue((byte)RioControl.Ack);
|
|
|
|
byte control = await gotControl.Task.WaitAsync(Timeout);
|
|
Assert.Equal((byte)RioControl.Ack, control);
|
|
|
|
cts.Cancel();
|
|
await run;
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AutoPoll_SendsAnalogRequest()
|
|
{
|
|
var fake = new FakeTransport();
|
|
var link = new RioSerialLink(fake, new RioSerialLinkOptions
|
|
{
|
|
AutoPollAnalog = true,
|
|
AnalogPollInterval = TimeSpan.FromMilliseconds(20),
|
|
});
|
|
|
|
using var cts = new CancellationTokenSource();
|
|
Task run = link.RunAsync(cts.Token);
|
|
|
|
byte[] firstWrite = await fake.NextWriteAsync();
|
|
Assert.Equal(PacketBuilder.AnalogRequest(), firstWrite);
|
|
|
|
cts.Cancel();
|
|
await run;
|
|
}
|
|
}
|