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>
250 lines
10 KiB
C#
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);
|
|
}
|
|
}
|
|
}
|