Files
riojoy/tests/RioJoy.Core.Tests/Profiles/ConfigStoreTests.cs
T
CydandClaude Fable 5 3512c89dca tray: --import-profile merges a profile document into the user config
RioJoy.Tray --import-profile <profile.json> merges a single-profile
document (a repo's profiles/*.json) into %APPDATA%\RIOJoy\config.json
via ConfigStore.ImportProfile: same-name profiles are replaced in place
(FindProfile's case-insensitive convention), everything else appended,
all other config content preserved. Refuses (exit 3) while a tray
instance is running - a live tray rewrites the config from memory and
would silently discard the import. Documents that a Name must be stated
in the file itself: RioProfile defaults Name to 'Unnamed', so the import
checks the raw JSON, not the deserialized object. This is the profile
install story for bench machines and pods (previously: hand-paste into
the Profiles array).

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

250 lines
10 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 ImportProfile_ShippedDescentFile_IntoFreshConfig()
{
string configPath = Path.Combine(Path.GetTempPath(), $"riojoy-import-{Guid.NewGuid():N}", "config.json");
try
{
string shipped = Path.Combine(TestRepo.Root(), "profiles", "descent-d1x.json");
ProfileImportResult result = ConfigStore.ImportProfile(configPath, shipped);
Assert.Equal("Descent", result.Name);
Assert.False(result.Replaced);
RioProfile p = Assert.Single(ConfigStore.Load(configPath).Profiles);
Assert.Equal("Descent", p.Name);
// The routing must survive the import round-trip intact — this is the
// exact payload a cabinet/bench install writes.
Assert.NotNull(p.AxisRouting);
Assert.Equal(new AxisRoute { Target = PadTarget.RightThumbY, Mode = AxisOutputMode.UnipolarPositive }, p.AxisRouting!.Z);
}
finally
{
string? dir = Path.GetDirectoryName(configPath);
if (dir is not null && Directory.Exists(dir))
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void ImportProfile_ReplacesSameName_PreservesEverythingElse()
{
string configPath = Path.Combine(Path.GetTempPath(), $"riojoy-import-{Guid.NewGuid():N}", "config.json");
try
{
ConfigStore.Save(new AppConfig
{
DefaultRioComPort = "COM7",
NeutralProfileName = "Desktop",
Profiles =
{
new RioProfile { Name = "Desktop" },
new RioProfile { Name = "descent", PlasmaGreeting = "STALE" }, // case-insensitive match
},
}, configPath);
string shipped = Path.Combine(TestRepo.Root(), "profiles", "descent-d1x.json");
ProfileImportResult result = ConfigStore.ImportProfile(configPath, shipped);
Assert.True(result.Replaced);
AppConfig back = ConfigStore.Load(configPath);
Assert.Equal("COM7", back.DefaultRioComPort); // untouched settings survive
Assert.Equal("Desktop", back.NeutralProfileName);
Assert.Equal(2, back.Profiles.Count); // replaced in place, not appended
RioProfile descent = Assert.IsType<RioProfile>(back.FindProfile("Descent"));
Assert.Equal("DESCENT", descent.PlasmaGreeting); // stale profile fully overwritten
}
finally
{
string? dir = Path.GetDirectoryName(configPath);
if (dir is not null && Directory.Exists(dir))
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void ImportProfile_NamelessProfile_Throws()
{
string dir = Path.Combine(Path.GetTempPath(), $"riojoy-import-{Guid.NewGuid():N}");
Directory.CreateDirectory(dir);
try
{
string profilePath = Path.Combine(dir, "nameless.json");
File.WriteAllText(profilePath, "{\"PlasmaGreeting\":\"X\"}");
Assert.Throws<Newtonsoft.Json.JsonSerializationException>(
() => ConfigStore.ImportProfile(Path.Combine(dir, "config.json"), profilePath));
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[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);
}
}
}