Files
riojoy/tests/RioJoy.Core.Tests/Profiles/ConfigStoreTests.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

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