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>
169 lines
7.0 KiB
C#
169 lines
7.0 KiB
C#
using RioJoy.Core.Calibration;
|
|
using RioJoy.Core.Output;
|
|
using RioJoy.Core.Profiles;
|
|
using Xunit;
|
|
|
|
namespace RioJoy.Core.Tests.Profiles;
|
|
|
|
public class ConfigStoreTests
|
|
{
|
|
[Fact]
|
|
public void RoundTrips_FullConfig()
|
|
{
|
|
var config = new AppConfig
|
|
{
|
|
DefaultRioComPort = "COM3",
|
|
DefaultPlasmaComPort = "COM4",
|
|
NativeGameExecutables = { "firestorm.exe", "redplanet.exe" },
|
|
NeutralProfileName = "Desktop",
|
|
Profiles =
|
|
{
|
|
new RioProfile
|
|
{
|
|
Name = "Doom",
|
|
RioComPort = "COM5",
|
|
PlasmaGreeting = "DOOM",
|
|
Buttons = { [0] = 0x8049, [0x50] = 0x1009 },
|
|
Calibration = new AxisCalibrationConfig { InvertY = true, EnableZR = false },
|
|
MatchExecutables = { "doom.exe" },
|
|
},
|
|
},
|
|
};
|
|
|
|
string json = ConfigStore.Serialize(config);
|
|
AppConfig back = ConfigStore.Deserialize(json);
|
|
|
|
Assert.Equal("COM3", back.DefaultRioComPort);
|
|
Assert.Equal(new[] { "firestorm.exe", "redplanet.exe" }, back.NativeGameExecutables);
|
|
|
|
RioProfile p = Assert.Single(back.Profiles);
|
|
Assert.Equal("Doom", p.Name);
|
|
Assert.Equal("COM5", p.RioComPort);
|
|
Assert.Equal(0x8049, p.Buttons[0]);
|
|
Assert.Equal(0x1009, p.Buttons[0x50]);
|
|
Assert.True(p.Calibration.InvertY);
|
|
Assert.False(p.Calibration.EnableZR);
|
|
Assert.Equal(new[] { "doom.exe" }, p.MatchExecutables);
|
|
}
|
|
|
|
[Fact]
|
|
public void RoundTrips_AxisRouting_WithStringEnums()
|
|
{
|
|
var config = new AppConfig
|
|
{
|
|
Profiles =
|
|
{
|
|
new RioProfile
|
|
{
|
|
Name = "Descent",
|
|
AxisRouting = 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 },
|
|
},
|
|
},
|
|
},
|
|
};
|
|
|
|
string json = ConfigStore.Serialize(config);
|
|
Assert.Contains("\"RightThumbY\"", json); // enums serialize as strings
|
|
Assert.Contains("\"UnipolarPositive\"", json); // (house StringEnumConverter convention)
|
|
|
|
RioProfile back = Assert.Single(ConfigStore.Deserialize(json).Profiles);
|
|
Assert.Equal(config.Profiles[0].AxisRouting, back.AxisRouting); // record value equality
|
|
Assert.Equal(PadTarget.LeftThumbX, back.AxisRouting!.X.Target); // untouched routes keep defaults
|
|
}
|
|
|
|
[Fact]
|
|
public void AxisRouting_Unset_StaysNull_AndOffJson()
|
|
{
|
|
// null = default routing; NullValueHandling.Ignore keeps it out of the JSON,
|
|
// so pre-existing profiles on disk are byte-compatible.
|
|
string json = ConfigStore.Serialize(new AppConfig { Profiles = { new RioProfile { Name = "P" } } });
|
|
Assert.DoesNotContain("AxisRouting", json);
|
|
Assert.Null(Assert.Single(ConfigStore.Deserialize(json).Profiles).AxisRouting);
|
|
}
|
|
|
|
[Fact]
|
|
public void ShippedDescentProfile_ParsesWithDescentRouting_NoTriggerTargets()
|
|
{
|
|
// Guards the shipped fixture itself: Newtonsoft's default
|
|
// MissingMemberHandling.Ignore silently drops a misspelled property or axis
|
|
// name, which would fall back to the default routing and put the throttle
|
|
// back on LeftTrigger — the stock fire button in Descent's SDL
|
|
// GameController mapping. So parse the real file, not a C#-built config.
|
|
string json = File.ReadAllText(Path.Combine(TestRepo.Root(), "profiles", "descent-d1x.json"));
|
|
|
|
// The shipped file is a single-profile document users paste into the
|
|
// config's Profiles list; wrap it so it flows through the exact
|
|
// ConfigStore serializer settings.
|
|
RioProfile p = Assert.Single(ConfigStore.Deserialize($"{{\"Profiles\":[{json}]}}").Profiles);
|
|
|
|
Assert.Equal("Descent", p.Name);
|
|
Assert.True(p.Calibration.EnableZR); // pedal-differential rudder feeds Rz
|
|
|
|
Assert.NotNull(p.AxisRouting);
|
|
AxisRoutingConfig routing = p.AxisRouting!;
|
|
Assert.Equal(new AxisRoute { Target = PadTarget.LeftThumbX }, routing.X);
|
|
Assert.Equal(new AxisRoute { Target = PadTarget.LeftThumbY }, routing.Y);
|
|
Assert.Equal(new AxisRoute { Target = PadTarget.RightThumbY, Mode = AxisOutputMode.UnipolarPositive }, routing.Z);
|
|
Assert.Equal(new AxisRoute { Target = PadTarget.RightThumbX }, routing.Rz);
|
|
Assert.Equal(new AxisRoute { Target = PadTarget.None }, routing.Rx);
|
|
Assert.Equal(new AxisRoute { Target = PadTarget.None }, routing.Ry);
|
|
|
|
// No axis may resolve onto a trigger — triggers are Descent's fire buttons.
|
|
foreach (JoyAxis axis in new[] { JoyAxis.X, JoyAxis.Y, JoyAxis.Z, JoyAxis.Rx, JoyAxis.Ry, JoyAxis.Rz })
|
|
{
|
|
PadTarget target = AxisRouter.Resolve(routing, axis, AxisOutputs.Center).Target;
|
|
Assert.NotEqual(PadTarget.LeftTrigger, target);
|
|
Assert.NotEqual(PadTarget.RightTrigger, target);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void ShippedDescentProfile_MatchesDxxRebirthReferenceCopy()
|
|
{
|
|
// The dxx-rebirth docs keep a reference copy of the Descent profile that
|
|
// must stay byte-identical to ours. The sibling checkout is a cabinet-
|
|
// machine convention, not a repo guarantee, so no-op quietly when the
|
|
// second repo is not checked out next to this one.
|
|
string reference = Path.GetFullPath(Path.Combine(
|
|
TestRepo.Root(), "..", "dxx-rebirth", "docs", "reference", "descent-riojoy-profile.json"));
|
|
if (!File.Exists(reference))
|
|
return;
|
|
|
|
string shipped = Path.Combine(TestRepo.Root(), "profiles", "descent-d1x.json");
|
|
Assert.Equal(File.ReadAllBytes(shipped), File.ReadAllBytes(reference));
|
|
}
|
|
|
|
[Fact]
|
|
public void Load_MissingFile_ReturnsDefaults()
|
|
{
|
|
string path = Path.Combine(Path.GetTempPath(), $"riojoy-missing-{Guid.NewGuid():N}.json");
|
|
AppConfig config = ConfigStore.Load(path);
|
|
Assert.Empty(config.Profiles);
|
|
Assert.Equal("COM1", config.DefaultRioComPort);
|
|
}
|
|
|
|
[Fact]
|
|
public void Save_ThenLoad_File()
|
|
{
|
|
string path = Path.Combine(Path.GetTempPath(), $"riojoy-{Guid.NewGuid():N}", "config.json");
|
|
try
|
|
{
|
|
var config = new AppConfig { DefaultRioComPort = "COM9" };
|
|
ConfigStore.Save(config, path);
|
|
Assert.True(File.Exists(path));
|
|
Assert.Equal("COM9", ConfigStore.Load(path).DefaultRioComPort);
|
|
}
|
|
finally
|
|
{
|
|
string? dir = Path.GetDirectoryName(path);
|
|
if (dir is not null && Directory.Exists(dir))
|
|
Directory.Delete(dir, recursive: true);
|
|
}
|
|
}
|
|
}
|