Bench round 3 (testlogs/riomash-patched-62500-sw): lamp glitches persisted because reply-resolution was type-blind — an analog reply still crossing USB from the previous poll could falsely confirm the NEXT command (usually a lamp write) before the board judged it. Now a reply resolves only its MATCHING pending request (analog/version/ check); ACK/NAK stay type-blind, which is safe because the board's TX ISR prioritizes ACK/NAK ahead of reply data, so a command's ACK cannot trail into its successor's window. Budget-exhausted drops settle 10ms before releasing the gate so late stragglers land on an empty pending. New test: a stray AnalogReply must not resolve a pending lamp command. 283 green; selftest regression unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
352 lines
12 KiB
C#
352 lines
12 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 AnalogReply_DoesNotResolve_PendingLampCommand()
|
|
{
|
|
var fake = new FakeTransport();
|
|
var link = new RioSerialLink(fake, StopAndWait(limit: 1, timeoutMs: 250));
|
|
|
|
using var cts = new CancellationTokenSource();
|
|
Task run = link.RunAsync(cts.Token);
|
|
|
|
// Lamp command in flight; a straggler analog reply (from an earlier
|
|
// poll) arrives. It must NOT confirm the lamp — the lamp keeps waiting
|
|
// and is resolved only by its own ACK.
|
|
Task send = link.SendAsync(PacketBuilder.LampRequest(0x05, 0x02));
|
|
await fake.NextWriteAsync();
|
|
fake.Enqueue(PacketBuilder.Build(RioCommand.AnalogReply, new byte[10]));
|
|
|
|
await Task.Delay(100);
|
|
Assert.False(send.IsCompleted, "stray analog reply must not resolve a lamp command");
|
|
|
|
fake.Enqueue((byte)RioControl.Ack);
|
|
await send.WaitAsync(Timeout);
|
|
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;
|
|
}
|
|
}
|