Files
CydandClaude Opus 4.8 b3cb764f4d Phase 2: serial + RIO protocol core (RioJoy.Core) with unit tests
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>
2026-06-26 13:04:03 -05:00

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;
}
}