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(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(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(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; } // --- NAK retransmit (RioSerialLinkOptions.NakRetransmitLimit) ------------ [Fact] public async Task Nak_RetransmitsLastCommand() { 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[] lamp = PacketBuilder.LampRequest(0x05, 0x02); await link.SendAsync(lamp); Assert.Equal(lamp, await fake.NextWriteAsync()); fake.Enqueue((byte)RioControl.Nak); // board: "that arrived corrupt" Assert.Equal(lamp, await fake.NextWriteAsync()); // resent Assert.Equal(1, link.NakRetransmits); cts.Cancel(); await run; } [Fact] public async Task Nak_RetransmitStopsAtLimit() { var fake = new FakeTransport(); var link = new RioSerialLink(fake, new RioSerialLinkOptions { AutoPollAnalog = false, NakRetransmitLimit = 2, }); using var cts = new CancellationTokenSource(); Task run = link.RunAsync(cts.Token); byte[] lamp = PacketBuilder.LampRequest(0x05, 0x02); await link.SendAsync(lamp); await fake.NextWriteAsync(); // original for (int i = 0; i < 4; i++) fake.Enqueue((byte)RioControl.Nak); Assert.Equal(lamp, await fake.NextWriteAsync()); // retry 1 Assert.Equal(lamp, await fake.NextWriteAsync()); // retry 2 // Budget spent: NAKs 3 and 4 must produce no further writes. await Assert.ThrowsAnyAsync( () => fake.NextWriteAsync(TimeSpan.FromMilliseconds(200))); Assert.Equal(2, link.NakRetransmits); cts.Cancel(); await run; } [Fact] public async Task Ack_DoesNotRetransmit() { var fake = new FakeTransport(); var link = new RioSerialLink(fake, new RioSerialLinkOptions { AutoPollAnalog = false }); using var cts = new CancellationTokenSource(); Task run = link.RunAsync(cts.Token); await link.SendAsync(PacketBuilder.LampRequest(0x05, 0x02)); await fake.NextWriteAsync(); fake.Enqueue((byte)RioControl.Ack); await Assert.ThrowsAnyAsync( () => fake.NextWriteAsync(TimeSpan.FromMilliseconds(200))); Assert.Equal(0, link.NakRetransmits); cts.Cancel(); await run; } [Fact] public async Task NewCommand_ResetsRetryBudget() { var fake = new FakeTransport(); var link = new RioSerialLink(fake, new RioSerialLinkOptions { AutoPollAnalog = false, NakRetransmitLimit = 1, }); using var cts = new CancellationTokenSource(); Task run = link.RunAsync(cts.Token); byte[] first = PacketBuilder.LampRequest(0x05, 0x02); await link.SendAsync(first); await fake.NextWriteAsync(); fake.Enqueue((byte)RioControl.Nak); Assert.Equal(first, await fake.NextWriteAsync()); // budget of 'first' spent byte[] second = PacketBuilder.LampRequest(0x06, 0x01); await link.SendAsync(second); Assert.Equal(second, await fake.NextWriteAsync()); fake.Enqueue((byte)RioControl.Nak); Assert.Equal(second, await fake.NextWriteAsync()); // fresh budget applies cts.Cancel(); await run; } [Fact] public async Task OwnControlByteReplies_AreNeverRetransmitted() { var fake = new FakeTransport(); var link = new RioSerialLink(fake, new RioSerialLinkOptions { AutoPollAnalog = false }); using var cts = new CancellationTokenSource(); Task run = link.RunAsync(cts.Token); // Inbound button packet -> the link replies with a 1-byte ACK. That ACK // must not become "the last command" for retransmit purposes. 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( () => fake.NextWriteAsync(TimeSpan.FromMilliseconds(200))); Assert.Equal(0, link.NakRetransmits); cts.Cancel(); await run; } [Fact] public async Task Retransmit_Disabled_IsFireAndForget() { var fake = new FakeTransport(); var link = new RioSerialLink(fake, new RioSerialLinkOptions { AutoPollAnalog = false, NakRetransmitLimit = 0, }); using var cts = new CancellationTokenSource(); Task run = link.RunAsync(cts.Token); await link.SendAsync(PacketBuilder.LampRequest(0x05, 0x02)); await fake.NextWriteAsync(); fake.Enqueue((byte)RioControl.Nak); await Assert.ThrowsAnyAsync( () => fake.NextWriteAsync(TimeSpan.FromMilliseconds(200))); Assert.Equal(0, link.NakRetransmits); cts.Cancel(); await run; } }