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>
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
using System.Threading.Channels;
|
||||
using RioJoy.Core.Serial;
|
||||
|
||||
namespace RioJoy.Core.Tests.Serial;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory <see cref="IRioTransport"/> for driving <see cref="RioSerialLink"/>
|
||||
/// in tests: enqueue inbound chunks, inspect outbound writes.
|
||||
/// </summary>
|
||||
internal sealed class FakeTransport : IRioTransport
|
||||
{
|
||||
private readonly Channel<byte[]> _incoming = Channel.CreateUnbounded<byte[]>();
|
||||
private readonly Channel<byte[]> _writes = Channel.CreateUnbounded<byte[]>();
|
||||
|
||||
public string Description => "fake";
|
||||
|
||||
/// <summary>Outbound writes, in order. Each call to WriteAsync yields one item.</summary>
|
||||
public ChannelReader<byte[]> Writes => _writes.Reader;
|
||||
|
||||
/// <summary>Queue an inbound chunk for the receive loop to read.</summary>
|
||||
public void Enqueue(params byte[] data) => _incoming.Writer.TryWrite(data);
|
||||
|
||||
/// <summary>Signal that no more inbound data will arrive (transport closed).</summary>
|
||||
public void CompleteIncoming() => _incoming.Writer.TryComplete();
|
||||
|
||||
public async ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken)
|
||||
{
|
||||
while (await _incoming.Reader.WaitToReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
if (_incoming.Reader.TryRead(out byte[]? chunk))
|
||||
{
|
||||
chunk.AsSpan().CopyTo(buffer.Span);
|
||||
return chunk.Length;
|
||||
}
|
||||
}
|
||||
|
||||
return 0; // completed
|
||||
}
|
||||
|
||||
public ValueTask WriteAsync(ReadOnlyMemory<byte> data, CancellationToken cancellationToken)
|
||||
{
|
||||
_writes.Writer.TryWrite(data.ToArray());
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>Read the next outbound write, failing if none arrives in time.</summary>
|
||||
public async Task<byte[]> NextWriteAsync(TimeSpan? timeout = null)
|
||||
{
|
||||
using var cts = new CancellationTokenSource(timeout ?? TimeSpan.FromSeconds(5));
|
||||
return await _writes.Reader.ReadAsync(cts.Token).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public void Dispose() { }
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user