Files
riojoy/tests/RioJoy.Core.Tests/Output/AxisRouterTests.cs
T
CydandClaude Fable 5 2f2438717a core: per-profile ViGEm axis routing with unipolar output mode
RioProfile gains a nullable AxisRouting section mapping each calibrated
axis (X/Y/Z/Rx/Ry/Rz) to a pad target (thumbs, triggers, or None) with a
Centered or UnipolarPositive conversion; the default reproduces the old
hardcoded routing exactly, so existing profiles are untouched. Routing
resolution lives in a pure, ViGEm-free AxisRouter for testability; the
sink neutralizes the pad on routing change so stale trigger state cannot
leak across profile switches.

Motivation: Descent reads the pad via SDL GameController, where the
triggers are its stock fire axis-buttons - the old fixed routing put
throttle on LeftTrigger (fires) and detent would have read as full
reverse. descent-d1x.json now routes Z->RightThumbY (UnipolarPositive,
detent = center) and Rz->RightThumbX, triggers untargeted; guarded by
tests that parse the shipped JSON through the real deserializer and
byte-compare the dxx-rebirth reference copy.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 09:00:09 -05:00

131 lines
5.2 KiB
C#

using RioJoy.Core.Calibration;
using RioJoy.Core.Output;
using Xunit;
namespace RioJoy.Core.Tests.Output;
public class AxisRouterTests
{
// --- Default routing reproduces the legacy hardcoded sink exactly --------
[Theory]
[InlineData(JoyAxis.X, PadTarget.LeftThumbX)]
[InlineData(JoyAxis.Y, PadTarget.LeftThumbY)]
[InlineData(JoyAxis.Rx, PadTarget.RightThumbX)]
[InlineData(JoyAxis.Ry, PadTarget.RightThumbY)]
public void Default_ThumbAxes_UseLegacyCenteredMath(JoyAxis axis, PadTarget expected)
{
var config = new AxisRoutingConfig();
// Legacy ToThumb: (value - 16383) * 2, clamped to the short range.
ResolvedAxis min = AxisRouter.Resolve(config, axis, 0);
Assert.Equal(expected, min.Target);
Assert.Equal(-32766, min.Value);
Assert.Equal(0, AxisRouter.Resolve(config, axis, AxisOutputs.Center).Value);
Assert.Equal(32766, AxisRouter.Resolve(config, axis, AxisOutputs.Max).Value);
}
[Theory]
[InlineData(JoyAxis.Z, PadTarget.LeftTrigger)]
[InlineData(JoyAxis.Rz, PadTarget.RightTrigger)]
public void Default_TriggerAxes_UseLegacyTriggerMath(JoyAxis axis, PadTarget expected)
{
var config = new AxisRoutingConfig();
// Legacy ToTrigger: value * 255 / 32766, clamped to the byte range.
ResolvedAxis rest = AxisRouter.Resolve(config, axis, 0);
Assert.Equal(expected, rest.Target);
Assert.Equal(0, rest.Value);
Assert.Equal(127, AxisRouter.Resolve(config, axis, AxisOutputs.Center).Value);
Assert.Equal(255, AxisRouter.Resolve(config, axis, AxisOutputs.Max).Value);
}
[Fact]
public void Default_CenteredThumb_ClampsToShortRange()
{
var config = new AxisRoutingConfig();
// (40000 - 16383) * 2 = 47234 → clamped to short.MaxValue.
Assert.Equal(32767, AxisRouter.Resolve(config, JoyAxis.X, 40000).Value);
// (-2000 - 16383) * 2 = -36766 → clamped to short.MinValue.
Assert.Equal(-32768, AxisRouter.Resolve(config, JoyAxis.X, -2000).Value);
}
// --- UnipolarPositive: clamp(value, 0, 32767) ----------------------------
[Fact]
public void UnipolarPositive_MapsCalibratedZeroToThumbCenter()
{
var config = new AxisRoutingConfig
{
Z = new AxisRoute { Target = PadTarget.RightThumbY, Mode = AxisOutputMode.UnipolarPositive },
};
Assert.Equal(0, AxisRouter.Resolve(config, JoyAxis.Z, 0).Value); // detent → center
Assert.Equal(16383, AxisRouter.Resolve(config, JoyAxis.Z, 16383).Value); // half → half up
Assert.Equal(32766, AxisRouter.Resolve(config, JoyAxis.Z, AxisOutputs.Max).Value); // full → max (32766 of 32767)
Assert.Equal(0, AxisRouter.Resolve(config, JoyAxis.Z, -5).Value); // clamps below
Assert.Equal(32767, AxisRouter.Resolve(config, JoyAxis.Z, 40000).Value); // clamps above
}
[Fact]
public void UnipolarPositive_OnTriggerTarget_IsIgnored_TriggerMathApplies()
{
// Mode is meaningless for trigger targets — they keep the trigger byte math.
var config = new AxisRoutingConfig
{
Z = new AxisRoute { Target = PadTarget.LeftTrigger, Mode = AxisOutputMode.UnipolarPositive },
};
Assert.Equal(255, AxisRouter.Resolve(config, JoyAxis.Z, AxisOutputs.Max).Value);
}
// --- None: the axis is not emitted ---------------------------------------
[Fact]
public void NoneTarget_EmitsNothing()
{
var config = new AxisRoutingConfig
{
Rx = new AxisRoute { Target = PadTarget.None },
};
ResolvedAxis r = AxisRouter.Resolve(config, JoyAxis.Rx, 12345);
Assert.Equal(PadTarget.None, r.Target);
Assert.Equal(0, r.Value);
}
// --- The Descent shape ----------------------------------------------------
[Fact]
public void DescentConfig_ThrottleToRightThumbY_TriggersUntargeted()
{
// profiles/descent-d1x.json: DXX's stock GameController defaults fire on
// the LT/RT axis-buttons, so no axis may land on a trigger.
var config = new AxisRoutingConfig
{
Z = new AxisRoute { Target = PadTarget.RightThumbY, Mode = AxisOutputMode.UnipolarPositive },
Rz = new AxisRoute { Target = PadTarget.RightThumbX },
Rx = new AxisRoute { Target = PadTarget.None },
Ry = new AxisRoute { Target = PadTarget.None },
};
ResolvedAxis z = AxisRouter.Resolve(config, JoyAxis.Z, 16383);
Assert.Equal(PadTarget.RightThumbY, z.Target);
Assert.Equal(16383, z.Value); // unipolar: half throttle → half deflection up
ResolvedAxis rz = AxisRouter.Resolve(config, JoyAxis.Rz, AxisOutputs.Center);
Assert.Equal(PadTarget.RightThumbX, rz.Target);
Assert.Equal(0, rz.Value); // rudder stays bipolar: center → thumb 0
foreach (JoyAxis axis in new[] { JoyAxis.X, JoyAxis.Y, JoyAxis.Z, JoyAxis.Rx, JoyAxis.Ry, JoyAxis.Rz })
{
PadTarget target = AxisRouter.Resolve(config, axis, AxisOutputs.Center).Target;
Assert.NotEqual(PadTarget.LeftTrigger, target);
Assert.NotEqual(PadTarget.RightTrigger, target);
}
}
}