using VRio.Core.Device;
using VRio.Core.Protocol;
using Xunit;
namespace VRio.Core.Tests;
public class VRioDeviceTests
{
/// Captures the device's transmissions and re-frames them for asserts.
private sealed class Wire
{
private readonly PacketParser _parser = new();
public readonly List Packets = new();
public readonly List Controls = new();
public Wire(VRioDevice device) => device.Transmit += OnBytes;
private void OnBytes(byte[] data)
{
foreach (byte b in data)
{
if (!_parser.Feed(b, out RioRxEvent ev))
continue;
if (ev.Kind == RioRxKind.Packet)
{
Assert.True(ev.ChecksumValid, $"device sent a bad checksum on {ev.Packet}");
Packets.Add(ev.Packet);
}
else if (ev.Kind == RioRxKind.ControlByte)
{
Controls.Add(ev.Byte);
}
}
}
public void Clear()
{
Packets.Clear();
Controls.Clear();
}
}
private static void Send(VRioDevice device, byte[] bytes) => device.OnReceived(bytes, bytes.Length);
[Fact]
public void AnalogRequest_returns_current_axes_and_acks()
{
var device = new VRioDevice();
var wire = new Wire(device);
device.SetAxis(RioAxis.Throttle, 1000);
device.SetAxis(RioAxis.JoystickY, 3000);
device.SetAxis(RioAxis.JoystickX, -5000);
Send(device, PacketBuilder.Build(RioCommand.AnalogRequest));
Assert.Equal((byte)RioControl.Ack, Assert.Single(wire.Controls));
RioPacket reply = Assert.Single(wire.Packets);
Assert.Equal(RioCommand.AnalogReply, reply.Command);
byte[] p = reply.Payload;
Assert.Equal(1000, AnalogCodec.Combine(p[0], p[1])); // throttle
Assert.Equal(0, AnalogCodec.Combine(p[2], p[3])); // left pedal
Assert.Equal(0, AnalogCodec.Combine(p[4], p[5])); // right pedal
Assert.Equal(-3000, AnalogCodec.Combine(p[6], p[7])); // joystick Y (up is negative on the wire)
Assert.Equal(-5000, AnalogCodec.Combine(p[8], p[9])); // joystick X
Assert.Equal(1, device.AnalogRequests);
}
[Fact]
public void Joystick_Y_is_negated_on_the_wire_but_not_locally()
{
var device = new VRioDevice();
var wire = new Wire(device);
device.SetAxis(RioAxis.JoystickY, 3000);
Send(device, PacketBuilder.Build(RioCommand.AnalogRequest));
byte[] p = Assert.Single(wire.Packets).Payload;
Assert.Equal(-3000, AnalogCodec.Combine(p[6], p[7]));
Assert.Equal(3000, device.GetAxis(RioAxis.JoystickY)); // the panel's dot is unflipped
Assert.Equal(-3000, device.GetWireAxis(RioAxis.JoystickY)); // the panel's readout matches the wire
// Full negative deflection: -Min is one past Max, so it clamps rather than throws.
device.SetAxis(RioAxis.JoystickY, AnalogCodec.Min);
wire.Clear();
Send(device, PacketBuilder.Build(RioCommand.AnalogRequest));
p = Assert.Single(wire.Packets).Payload;
Assert.Equal(AnalogCodec.Max, AnalogCodec.Combine(p[6], p[7]));
}
[Fact]
public void InvertJoystickY_sends_the_physical_direction_instead()
{
var device = new VRioDevice { InvertJoystickY = true };
var wire = new Wire(device);
device.SetAxis(RioAxis.JoystickY, 3000);
Send(device, PacketBuilder.Build(RioCommand.AnalogRequest));
byte[] p = Assert.Single(wire.Packets).Payload;
Assert.Equal(3000, AnalogCodec.Combine(p[6], p[7]));
Assert.Equal(3000, device.GetWireAxis(RioAxis.JoystickY));
}
[Fact]
public void VersionRequest_reports_configured_firmware()
{
var device = new VRioDevice { VersionMajor = 2, VersionMinor = 7 };
var wire = new Wire(device);
Send(device, PacketBuilder.Build(RioCommand.VersionRequest));
RioPacket reply = Assert.Single(wire.Packets);
Assert.Equal(RioCommand.VersionReply, reply.Command);
Assert.Equal(new byte[] { 2, 7 }, reply.Payload);
}
[Fact]
public void CheckRequest_enters_test_mode_reports_boards_then_exits()
{
var device = new VRioDevice();
var wire = new Wire(device);
Send(device, PacketBuilder.Build(RioCommand.CheckRequest));
// The init handshake: TestModeChange ENTER, one CheckReply per board,
// TestModeChange EXIT. The game waits on both test-mode packets and
// stays mute forever if the EXIT never arrives.
Assert.Equal(RioAddressSpace.Boards.Count + 2, wire.Packets.Count);
RioPacket enter = wire.Packets[0];
Assert.Equal(RioCommand.TestModeChange, enter.Command);
Assert.Equal(new byte[] { 1 }, enter.Payload);
RioPacket exit = wire.Packets[wire.Packets.Count - 1];
Assert.Equal(RioCommand.TestModeChange, exit.Command);
Assert.Equal(new byte[] { 0 }, exit.Payload);
var checks = wire.Packets.GetRange(1, RioAddressSpace.Boards.Count);
Assert.All(checks, p =>
{
Assert.Equal(RioCommand.CheckReply, p.Command);
Assert.Equal((byte)RioStatusType.BoardOk, p.Payload[0]);
});
Assert.Equal(
RioAddressSpace.Boards.Select(b => b.Number),
checks.Select(p => p.Payload[1]));
}
[Fact]
public void LampRequest_updates_lamp_state_and_raises_event()
{
var device = new VRioDevice();
_ = new Wire(device);
(int Address, byte State)? change = null;
device.LampChanged += (a, s) => change = (a, s);
Send(device, PacketBuilder.Build(RioCommand.LampRequest, new byte[] { 0x05, RioLampState.SolidBright }));
Assert.Equal((0x05, RioLampState.SolidBright), change);
Assert.Equal(RioLampState.SolidBright, device.GetLamp(0x05));
}
[Fact]
public void ResetRequest_zeroes_the_targeted_axis()
{
var device = new VRioDevice();
_ = new Wire(device);
device.SetAxis(RioAxis.Throttle, 4000);
device.SetAxis(RioAxis.JoystickY, -3000);
Send(device, PacketBuilder.Build(RioCommand.ResetRequest, new[] { (byte)RioResetTarget.Throttle }));
Assert.Equal(0, device.GetAxis(RioAxis.Throttle));
Assert.Equal(-3000, device.GetAxis(RioAxis.JoystickY)); // untouched
Send(device, PacketBuilder.Build(RioCommand.ResetRequest, new[] { (byte)RioResetTarget.All }));
Assert.Equal(0, device.GetAxis(RioAxis.JoystickY));
}
[Fact]
public void Bad_checksum_gets_nak_and_no_reply()
{
var device = new VRioDevice();
var wire = new Wire(device);
byte[] bad = PacketBuilder.Build(RioCommand.AnalogRequest);
bad[bad.Length - 1] ^= 0x01; // corrupt the checksum
Send(device, bad);
Assert.Equal((byte)RioControl.Nak, Assert.Single(wire.Controls));
Assert.Empty(wire.Packets);
Assert.Equal(1, device.BadChecksums);
}
[Fact]
public void Button_press_and_release_use_button_packets()
{
var device = new VRioDevice();
var wire = new Wire(device);
device.PressAddress(0x3D); // Panic
device.ReleaseAddress(0x3D);
Assert.Equal(2, wire.Packets.Count);
Assert.Equal(RioCommand.ButtonPressed, wire.Packets[0].Command);
Assert.Equal(new byte[] { 0x3D }, wire.Packets[0].Payload);
Assert.Equal(RioCommand.ButtonReleased, wire.Packets[1].Command);
}
[Theory]
[InlineData(0x51, 0, 0x1)] // internal keypad "1"
[InlineData(0x5F, 0, 0xF)] // internal keypad "F"
[InlineData(0x60, 1, 0x0)] // external keypad "0"
[InlineData(0x6C, 1, 0xC)] // external keypad "C"
public void Keypad_press_uses_key_packets_with_pad_and_index(int address, byte pad, byte index)
{
var device = new VRioDevice();
var wire = new Wire(device);
device.PressAddress(address);
RioPacket packet = Assert.Single(wire.Packets);
Assert.Equal(RioCommand.KeyPressed, packet.Command);
Assert.Equal(new[] { pad, index }, packet.Payload);
}
[Fact]
public void Nak_resends_four_times_then_gives_up_with_restart()
{
var device = new VRioDevice();
var wire = new Wire(device);
device.PressAddress(0x00);
Assert.Single(wire.Packets);
// The v4.2 firmware retry budget: 4 re-sends, then RESTART and give up.
for (int i = 0; i < 4; i++)
Send(device, new[] { (byte)RioControl.Nak });
Assert.Equal(5, wire.Packets.Count);
Assert.All(wire.Packets, p => Assert.Equal(RioCommand.ButtonPressed, p.Command));
Assert.Empty(wire.Controls);
Send(device, new[] { (byte)RioControl.Nak }); // budget exhausted
Assert.Equal(5, wire.Packets.Count);
Assert.Equal((byte)RioControl.Restart, Assert.Single(wire.Controls));
// An ACK clears the pending event; a following NAK re-sends nothing.
wire.Clear();
device.PressAddress(0x01);
Send(device, new[] { (byte)RioControl.Ack });
Send(device, new[] { (byte)RioControl.Nak });
Assert.Single(wire.Packets);
Assert.Empty(wire.Controls);
}
[Fact]
public void Wedge_emulation_mutes_analog_until_host_reset()
{
var device = new VRioDevice { EmulateReplyWedge = true };
var wire = new Wire(device);
// Exhaust the retry budget to trip the latch leak.
device.PressAddress(0x00);
for (int i = 0; i < 5; i++)
Send(device, new[] { (byte)RioControl.Nak });
Assert.True(device.AnalogWedged);
wire.Clear();
// Wedged: the request is ACK'd (RX path alive) but no reply comes back.
Send(device, PacketBuilder.Build(RioCommand.AnalogRequest));
Assert.Equal((byte)RioControl.Ack, Assert.Single(wire.Controls));
Assert.Empty(wire.Packets);
Assert.Equal(1, device.AnalogDropped);
// The host's recovery reset clears the latch; analog replies resume.
Send(device, PacketBuilder.Build(RioCommand.ResetRequest, new[] { (byte)RioResetTarget.All }));
Assert.False(device.AnalogWedged);
wire.Clear();
Send(device, PacketBuilder.Build(RioCommand.AnalogRequest));
Assert.Equal(RioCommand.AnalogReply, Assert.Single(wire.Packets).Command);
}
[Fact]
public void WedgeAnalogNow_wedges_without_the_emulation_flag()
{
var device = new VRioDevice();
var wire = new Wire(device);
device.WedgeAnalogNow();
Assert.True(device.AnalogWedged);
Send(device, PacketBuilder.Build(RioCommand.AnalogRequest));
Assert.Empty(wire.Packets);
Send(device, PacketBuilder.Build(RioCommand.ResetRequest, new[] { (byte)RioResetTarget.Throttle }));
Assert.False(device.AnalogWedged);
}
[Fact]
public void Axis_values_clamp_to_wire_range()
{
var device = new VRioDevice();
device.SetAxis(RioAxis.Throttle, 60000);
Assert.Equal(AnalogCodec.Max, device.GetAxis(RioAxis.Throttle));
device.SetAxis(RioAxis.Throttle, -60000);
Assert.Equal(AnalogCodec.Min, device.GetAxis(RioAxis.Throttle));
}
}