Files
riojoy/tests/RioJoy.Core.Tests/Serial/RioSerialLinkTests.cs
T
CydandClaude Fable 5 47df14cc61 RioSerialLink: stop-and-wait command delivery (supersedes NAK-race resend)
Bench falsified the v1 retransmit (testlogs/riomash-patched-62500-retx):
resends tracked NAKs 1:1 (321/321) yet lamps still stuck/missed — under
mash bursts the NAK arrives after a newer command is already "latest",
so the wrong packet was resent; and total shreds never NAK at all.

Now commands are stop-and-wait: ONE in flight (_commandGate), resolved
by ACK, NAK, or AckTimeout (50ms); NAK/timeout retransmits THE SAME
packet up to CommandRetransmitLimit (2), then drops (idempotent - the
next state update supersedes). Attribution is exact by construction and
timeouts catch silent shreds. Control-byte replies bypass the gate so
board traffic is never delayed; a request's own reply (analog/version/
check) also resolves the wait, so request/reply exchanges never burn
the timeout even if the board sends no explicit ACK.

7 tests (same-packet resend, timeout retry+drop, ACK completion,
serialization, reply-resolves-request, stray-NAK no-op, disable);
282 green. Mash summary now reports NAK/timeout resends.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 13:20:30 -05:00

325 lines
11 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;
}
// --- stop-and-wait command retransmit (CommandRetransmitLimit) -----------
private static RioSerialLinkOptions StopAndWait(int limit = 2, int timeoutMs = 150) => new()
{
AutoPollAnalog = false,
CommandRetransmitLimit = limit,
AckTimeout = TimeSpan.FromMilliseconds(timeoutMs),
};
[Fact]
public async Task Nak_RetransmitsTheSamePacket()
{
var fake = new FakeTransport();
var link = new RioSerialLink(fake, StopAndWait());
using var cts = new CancellationTokenSource();
Task run = link.RunAsync(cts.Token);
byte[] lamp = PacketBuilder.LampRequest(0x05, 0x02);
Task send = link.SendAsync(lamp);
Assert.Equal(lamp, await fake.NextWriteAsync());
fake.Enqueue((byte)RioControl.Nak); // board: "that arrived corrupt"
Assert.Equal(lamp, await fake.NextWriteAsync()); // exact same packet again
fake.Enqueue((byte)RioControl.Ack); // second copy lands
await send.WaitAsync(Timeout);
Assert.Equal(1, link.Retransmits);
cts.Cancel();
await run;
}
[Fact]
public async Task Timeout_RetransmitsWhenBoardNeverResponds()
{
var fake = new FakeTransport();
var link = new RioSerialLink(fake, StopAndWait(limit: 2, timeoutMs: 80));
using var cts = new CancellationTokenSource();
Task run = link.RunAsync(cts.Token);
byte[] lamp = PacketBuilder.LampRequest(0x05, 0x02);
Task send = link.SendAsync(lamp);
// Original + 2 timeout-driven retries, then the command is dropped.
Assert.Equal(lamp, await fake.NextWriteAsync());
Assert.Equal(lamp, await fake.NextWriteAsync());
Assert.Equal(lamp, await fake.NextWriteAsync());
await send.WaitAsync(Timeout); // completes (dropped), doesn't hang
Assert.Equal(2, link.Retransmits);
await Assert.ThrowsAnyAsync<OperationCanceledException>(
() => fake.NextWriteAsync(TimeSpan.FromMilliseconds(200)));
cts.Cancel();
await run;
}
[Fact]
public async Task Ack_CompletesWithoutRetransmit()
{
var fake = new FakeTransport();
var link = new RioSerialLink(fake, StopAndWait());
using var cts = new CancellationTokenSource();
Task run = link.RunAsync(cts.Token);
Task send = link.SendAsync(PacketBuilder.LampRequest(0x05, 0x02));
await fake.NextWriteAsync();
fake.Enqueue((byte)RioControl.Ack);
await send.WaitAsync(Timeout);
Assert.Equal(0, link.Retransmits);
await Assert.ThrowsAnyAsync<OperationCanceledException>(
() => fake.NextWriteAsync(TimeSpan.FromMilliseconds(200)));
cts.Cancel();
await run;
}
[Fact]
public async Task Commands_AreSerialized_SecondWaitsForFirst()
{
var fake = new FakeTransport();
var link = new RioSerialLink(fake, StopAndWait());
using var cts = new CancellationTokenSource();
Task run = link.RunAsync(cts.Token);
byte[] first = PacketBuilder.LampRequest(0x05, 0x02);
byte[] second = PacketBuilder.LampRequest(0x06, 0x01);
Task sendA = link.SendAsync(first);
Task sendB = link.SendAsync(second);
// Only the first is on the wire until it resolves.
Assert.Equal(first, await fake.NextWriteAsync());
await Assert.ThrowsAnyAsync<OperationCanceledException>(
() => fake.NextWriteAsync(TimeSpan.FromMilliseconds(100)));
fake.Enqueue((byte)RioControl.Ack);
Assert.Equal(second, await fake.NextWriteAsync()); // now B goes out
fake.Enqueue((byte)RioControl.Ack);
await sendA.WaitAsync(Timeout);
await sendB.WaitAsync(Timeout);
cts.Cancel();
await run;
}
[Fact]
public async Task RequestReply_ResolvesTheInFlightCommand_WithoutExplicitAck()
{
var fake = new FakeTransport();
var link = new RioSerialLink(fake, StopAndWait());
using var cts = new CancellationTokenSource();
Task run = link.RunAsync(cts.Token);
// An analog request answered by the reply alone (no ACK byte) must not
// burn the ACK timeout — the reply proves the request landed.
Task send = link.RequestAnalogAsync();
await fake.NextWriteAsync();
fake.Enqueue(PacketBuilder.Build(RioCommand.AnalogReply, new byte[10]));
await send.WaitAsync(TimeSpan.FromMilliseconds(120)); // well under AckTimeout
Assert.Equal(0, link.Retransmits);
cts.Cancel();
await run;
}
[Fact]
public async Task UnsolicitedNak_WithNothingPending_IsIgnored()
{
var fake = new FakeTransport();
var link = new RioSerialLink(fake, StopAndWait());
using var cts = new CancellationTokenSource();
Task run = link.RunAsync(cts.Token);
// Inbound button packet -> our 1-byte ACK reply (bypasses the command
// gate). A stray NAK afterwards must not resend anything.
fake.Enqueue(PacketBuilder.Build(RioCommand.ButtonPressed, new byte[] { 0x05 }));
Assert.Equal(new byte[] { (byte)RioControl.Ack }, await fake.NextWriteAsync());
fake.Enqueue((byte)RioControl.Nak);
await Assert.ThrowsAnyAsync<OperationCanceledException>(
() => fake.NextWriteAsync(TimeSpan.FromMilliseconds(200)));
Assert.Equal(0, link.Retransmits);
cts.Cancel();
await run;
}
[Fact]
public async Task Retransmit_Disabled_IsFireAndForget()
{
var fake = new FakeTransport();
var link = new RioSerialLink(fake, StopAndWait(limit: 0));
using var cts = new CancellationTokenSource();
Task run = link.RunAsync(cts.Token);
// Completes immediately (no ACK wait), and a NAK triggers nothing.
await link.SendAsync(PacketBuilder.LampRequest(0x05, 0x02)).WaitAsync(Timeout);
await fake.NextWriteAsync();
fake.Enqueue((byte)RioControl.Nak);
await Assert.ThrowsAnyAsync<OperationCanceledException>(
() => fake.NextWriteAsync(TimeSpan.FromMilliseconds(200)));
Assert.Equal(0, link.Retransmits);
cts.Cancel();
await run;
}
}