Files
CydandClaude Opus 4.8 cc64d241c9 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>
2026-06-26 15:10:06 -05:00

169 lines
5.0 KiB
C#

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);
}
}