Phase 3: input mapping + output routing (RioJoy.Core.Mapping)
Port the iRIO decode and Press_V2/Release_V2 routing into testable C#: - Mapping/: RioMapEntry decodes the 16-bit map word (lamp/mouse/hat/joy/extended/ alt/ctrl/shift flags + value) and resolves the routing Kind by the legacy precedence (joy+hat+mouse => RIO command; none => keyboard; else joy>hat>mouse). RioAddress translates button/keypad events to table addresses (+0x50/+0x60 keypad offsets); RioInputMap is the 112-entry per-profile table replacing the hard-coded iRIO[]. - InputRouter ports Press_V2/Release_V2: modifier press/release ordering, scancode keys, joystick buttons, POV hat, mouse move/click (deltas corrected per PROTOCOL.md), RIO-command dispatch, and lamp feedback (bright on press, dim on release; RIO commands carry none). InitializeLamps() dims all lamp entries. - Output is split behind sink interfaces (IInputSink/IJoystickSink/ILampSink/ IRioCommandSink) so routing is pure + unit-tested; Output/SendInputSink is the real SendInput keyboard/mouse adapter (scancode injection). - tests: 30 new xUnit tests (84 total) for entry decode, address translation, and router routing/precedence/lamp/modifier ordering via a recording sink. The joystick sink's real adapter (HID feeder -> RioGamepad via DeviceIoControl) is blocked on the Phase 1 driver; routing already targets IJoystickSink. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,168 @@
|
||||
using RioJoy.Core.Mapping;
|
||||
using Xunit;
|
||||
|
||||
namespace RioJoy.Core.Tests.Mapping;
|
||||
|
||||
public class InputRouterTests
|
||||
{
|
||||
private const byte SolidDim = 0x14;
|
||||
private const byte SolidBright = 0x3C;
|
||||
|
||||
private static (InputRouter router, RecordingSink sink) Build(
|
||||
int address, ushort raw, int mouseStep = 50)
|
||||
{
|
||||
var map = new RioInputMap { [address] = new RioMapEntry(raw) };
|
||||
var sink = new RecordingSink();
|
||||
var router = new InputRouter(map, sink, sink, sink, sink, mouseStep);
|
||||
return (router, sink);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Keyboard_WithModifiers_PressesInOrderAndLights()
|
||||
{
|
||||
// 0x8749 = hasLamp + alt + ctrl + shift, VK 0x49
|
||||
(InputRouter router, RecordingSink sink) = Build(0x05, 0x8749);
|
||||
|
||||
router.Press(0x05);
|
||||
Assert.Equal(new[]
|
||||
{
|
||||
"KeyDown(0x10,ext=False)", // SHIFT
|
||||
"KeyDown(0x11,ext=False)", // CTRL
|
||||
"KeyDown(0x12,ext=False)", // ALT
|
||||
"KeyDown(0x49,ext=False)", // key
|
||||
$"Lamp(0x05,0x{SolidBright:X2})",
|
||||
}, sink.Log);
|
||||
|
||||
sink.Log.Clear();
|
||||
router.Release(0x05);
|
||||
Assert.Equal(new[]
|
||||
{
|
||||
"KeyUp(0x49,ext=False)", // key first
|
||||
"KeyUp(0x12,ext=False)", // then ALT, CTRL, SHIFT (reverse)
|
||||
"KeyUp(0x11,ext=False)",
|
||||
"KeyUp(0x10,ext=False)",
|
||||
$"Lamp(0x05,0x{SolidDim:X2})",
|
||||
}, sink.Log);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Keyboard_Extended_NoLamp()
|
||||
{
|
||||
// 0x08BE = extended, VK 0xBE, no lamp
|
||||
(InputRouter router, RecordingSink sink) = Build(0x10, 0x08BE);
|
||||
|
||||
router.Press(0x10);
|
||||
Assert.Equal(new[] { "KeyDown(0xBE,ext=True)" }, sink.Log);
|
||||
|
||||
sink.Log.Clear();
|
||||
router.Release(0x10);
|
||||
Assert.Equal(new[] { "KeyUp(0xBE,ext=True)" }, sink.Log);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Joystick_PressReleaseWithLamp()
|
||||
{
|
||||
// 0x9009 = hasLamp + joy, button 9
|
||||
(InputRouter router, RecordingSink sink) = Build(0x03, 0x9009);
|
||||
|
||||
router.Press(0x03);
|
||||
Assert.Equal(new[] { "Joy(9,True)", $"Lamp(0x03,0x{SolidBright:X2})" }, sink.Log);
|
||||
|
||||
sink.Log.Clear();
|
||||
router.Release(0x03);
|
||||
Assert.Equal(new[] { "Joy(9,False)", $"Lamp(0x03,0x{SolidDim:X2})" }, sink.Log);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Hat_PressSetsDirection_ReleaseCenters()
|
||||
{
|
||||
// 0x2002 = hat, value 2 = Down
|
||||
(InputRouter router, RecordingSink sink) = Build(0x04, 0x2002);
|
||||
|
||||
router.Press(0x04);
|
||||
Assert.Equal(new[] { "Hat(Down)" }, sink.Log);
|
||||
|
||||
sink.Log.Clear();
|
||||
router.Release(0x04);
|
||||
Assert.Equal(new[] { "Hat(Centered)" }, sink.Log);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0x4000, "MouseMove(0,-50)")] // up
|
||||
[InlineData(0x4001, "MouseMove(50,0)")] // right
|
||||
[InlineData(0x4002, "MouseMove(0,50)")] // down
|
||||
[InlineData(0x4003, "MouseMove(-50,0)")] // left
|
||||
public void Mouse_MovePressOnly(ushort raw, string expectedMove)
|
||||
{
|
||||
(InputRouter router, RecordingSink sink) = Build(0x06, raw);
|
||||
|
||||
router.Press(0x06);
|
||||
Assert.Equal(new[] { expectedMove }, sink.Log);
|
||||
|
||||
sink.Log.Clear();
|
||||
router.Release(0x06); // moves do nothing on release
|
||||
Assert.Empty(sink.Log);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mouse_ClickDownAndUp()
|
||||
{
|
||||
(InputRouter router, RecordingSink sink) = Build(0x06, 0x4004); // left click
|
||||
|
||||
router.Press(0x06);
|
||||
router.Release(0x06);
|
||||
Assert.Equal(new[] { "MouseButton(Left,True)", "MouseButton(Left,False)" }, sink.Log);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mouse_MoveStepIsConfigurable()
|
||||
{
|
||||
(InputRouter router, RecordingSink sink) = Build(0x06, 0x4001, mouseStep: 10);
|
||||
router.Press(0x06);
|
||||
Assert.Equal(new[] { "MouseMove(10,0)" }, sink.Log);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RioCommand_Executes_AndSkipsLampEvenIfFlagged()
|
||||
{
|
||||
// 0xF006 = hasLamp + joy + hat + mouse (command), value 6 = GeneralReset
|
||||
(InputRouter router, RecordingSink sink) = Build(0x07, 0xF006);
|
||||
|
||||
router.Press(0x07);
|
||||
Assert.Equal(new[] { "Cmd(GeneralReset)" }, sink.Log); // no lamp
|
||||
|
||||
sink.Log.Clear();
|
||||
router.Release(0x07);
|
||||
Assert.Empty(sink.Log); // release does nothing
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unmapped_Entry_DoesNothing()
|
||||
{
|
||||
(InputRouter router, RecordingSink sink) = Build(0x08, 0x0000);
|
||||
router.Press(0x08);
|
||||
router.Release(0x08);
|
||||
Assert.Empty(sink.Log);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InitializeLamps_DimsOnlyLampEntries()
|
||||
{
|
||||
var map = new RioInputMap
|
||||
{
|
||||
[0x00] = new RioMapEntry(0x8041), // hasLamp keyboard
|
||||
[0x01] = new RioMapEntry(0x0041), // no lamp
|
||||
[0x02] = new RioMapEntry(0x9009), // hasLamp joystick
|
||||
};
|
||||
var sink = new RecordingSink();
|
||||
var router = new InputRouter(map, sink, sink, sink, sink);
|
||||
|
||||
router.InitializeLamps();
|
||||
|
||||
Assert.Equal(new[]
|
||||
{
|
||||
$"Lamp(0x00,0x{SolidDim:X2})",
|
||||
$"Lamp(0x02,0x{SolidDim:X2})",
|
||||
}, sink.Log);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using RioJoy.Core.Mapping;
|
||||
|
||||
namespace RioJoy.Core.Tests.Mapping;
|
||||
|
||||
/// <summary>
|
||||
/// Records every routed output as an ordered, human-readable log so tests can
|
||||
/// assert both <i>what</i> happened and <i>in what order</i>.
|
||||
/// </summary>
|
||||
internal sealed class RecordingSink : IInputSink, IJoystickSink, ILampSink, IRioCommandSink
|
||||
{
|
||||
public List<string> Log { get; } = new();
|
||||
|
||||
public void KeyDown(byte virtualKey, bool extended) => Log.Add($"KeyDown(0x{virtualKey:X2},ext={extended})");
|
||||
|
||||
public void KeyUp(byte virtualKey, bool extended) => Log.Add($"KeyUp(0x{virtualKey:X2},ext={extended})");
|
||||
|
||||
public void MouseMove(int dx, int dy) => Log.Add($"MouseMove({dx},{dy})");
|
||||
|
||||
public void MouseButton(MouseButton button, bool down) => Log.Add($"MouseButton({button},{down})");
|
||||
|
||||
public void SetButton(int button, bool pressed) => Log.Add($"Joy({button},{pressed})");
|
||||
|
||||
public void SetHat(RioHat position) => Log.Add($"Hat({position})");
|
||||
|
||||
public void SetLamp(int address, byte lampState) => Log.Add($"Lamp(0x{address:X2},0x{lampState:X2})");
|
||||
|
||||
public void Execute(RioCommandCode command) => Log.Add($"Cmd({command})");
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using RioJoy.Core.Mapping;
|
||||
using Xunit;
|
||||
|
||||
namespace RioJoy.Core.Tests.Mapping;
|
||||
|
||||
public class RioAddressTests
|
||||
{
|
||||
[Fact]
|
||||
public void Button_MapsDirectly()
|
||||
{
|
||||
Assert.Equal(0x00, RioAddress.FromButton(0x00));
|
||||
Assert.Equal(0x47, RioAddress.FromButton(0x47));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Button_RejectsOutOfRange()
|
||||
{
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => RioAddress.FromButton(0x48));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, 0x00, 0x50)]
|
||||
[InlineData(0, 0x0F, 0x5F)]
|
||||
[InlineData(1, 0x00, 0x60)]
|
||||
[InlineData(1, 0x0F, 0x6F)]
|
||||
public void Keypad_AppliesOffset(byte pad, byte index, int expected)
|
||||
{
|
||||
Assert.Equal(expected, RioAddress.FromKeypad(pad, index));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Keypad_RejectsBadPadOrIndex()
|
||||
{
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => RioAddress.FromKeypad(2, 0));
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => RioAddress.FromKeypad(0, 0x10));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TableSize_Is112()
|
||||
{
|
||||
Assert.Equal(112, RioAddress.TableSize);
|
||||
Assert.Equal(0x6F, RioAddress.MaxAddress);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
using RioJoy.Core.Mapping;
|
||||
using Xunit;
|
||||
|
||||
namespace RioJoy.Core.Tests.Mapping;
|
||||
|
||||
public class RioMapEntryTests
|
||||
{
|
||||
[Fact]
|
||||
public void Keyboard_NoRoutingFlags_DecodesModifiersAndValue()
|
||||
{
|
||||
// 0x8749 = hasLamp + alt + ctrl + shift, VK 0x49 ('I')
|
||||
var e = new RioMapEntry(0x8749);
|
||||
Assert.Equal(RioRouteKind.Keyboard, e.Kind);
|
||||
Assert.True(e.HasLamp);
|
||||
Assert.True(e.Alt);
|
||||
Assert.True(e.Ctrl);
|
||||
Assert.True(e.Shift);
|
||||
Assert.False(e.Extended);
|
||||
Assert.Equal(0x49, e.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Extended_FlagDecodes()
|
||||
{
|
||||
// 0x88BE = hasLamp + extended, VK 0xBE
|
||||
var e = new RioMapEntry(0x88BE);
|
||||
Assert.Equal(RioRouteKind.Keyboard, e.Kind);
|
||||
Assert.True(e.HasLamp);
|
||||
Assert.True(e.Extended);
|
||||
Assert.Equal(0xBE, e.Value);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0x1009, RioRouteKind.Joystick)] // joy flag
|
||||
[InlineData(0x2003, RioRouteKind.Hat)] // hat flag
|
||||
[InlineData(0x4002, RioRouteKind.Mouse)] // mouse flag
|
||||
[InlineData(0x7005, RioRouteKind.RioCommand)] // joy+hat+mouse → command
|
||||
[InlineData(0x0041, RioRouteKind.Keyboard)] // none → keyboard
|
||||
public void Kind_FollowsPrecedence(ushort raw, RioRouteKind expected)
|
||||
{
|
||||
Assert.Equal(expected, new RioMapEntry(raw).Kind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Joystick_BeatsHatAndMouse_WhenMultipleSet()
|
||||
{
|
||||
// joy + hat set (but not mouse) → joystick wins
|
||||
Assert.Equal(RioRouteKind.Joystick, new RioMapEntry(0x3001).Kind);
|
||||
// hat + mouse set (but not joy) → hat wins
|
||||
Assert.Equal(RioRouteKind.Hat, new RioMapEntry(0x6001).Kind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unmapped_IsZero()
|
||||
{
|
||||
Assert.True(new RioMapEntry(0).IsUnmapped);
|
||||
Assert.False(new RioMapEntry(0x0041).IsUnmapped);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user