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; } // --- 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( () => 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( () => 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( () => 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( () => 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( () => fake.NextWriteAsync(TimeSpan.FromMilliseconds(200))); Assert.Equal(0, link.Retransmits); cts.Cancel(); await run; } }