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