using System; using System.IO; using System.Linq; using NUnit.Framework; using UnityEngine; using UnityEngine.InputSystem; namespace Ironspore.Tests { public sealed class SettingsTests { string folder, path; InputActionAsset input; SettingsService service; GameSettingsData defaults; [SetUp] public void Setup() { folder = Path.Combine(Path.GetTempPath(), "IronsporeSettingsTests", Guid.NewGuid().ToString("N")); Directory.CreateDirectory(folder); path = Path.Combine(folder, "settings.json"); input = InputActionAsset.FromJson(File.ReadAllText("Assets/Game/Input/GameInput.inputactions")); defaults = new GameSettingsData(); } [TearDown] public void Cleanup() { service?.Dispose(); service = null; if (input) UnityEngine.Object.DestroyImmediate(input); string allowed = Path.GetFullPath(Path.Combine(Path.GetTempPath(), "IronsporeSettingsTests")) + Path.DirectorySeparatorChar; if (Path.GetFullPath(folder).StartsWith(allowed, StringComparison.OrdinalIgnoreCase) && Directory.Exists(folder)) Directory.Delete(folder, true); } SettingsService StartService(string file = null, Func clock = null) => service = new SettingsService(new SettingsStore(file ?? path, defaults), input, defaults, clock); [Test] public void CompleteSnapshotSurvivesReloadAndCallerEdits() { var store = new SettingsStore(path); var candidate = new GameSettingsData { masterVolume = .35f, mouseSensitivity = 2.5f, aimSensitivity = .6f, qualityLevel = 4, resolutionWidth = 1920, resolutionHeight = 1080, fullscreen = true, bindingOverrides = "stable input IDs" }; Assert.IsTrue(store.Save(candidate)); candidate.masterVolume = 0; var loaded = new SettingsStore(path).Data; Assert.AreEqual(.35f, loaded.masterVolume); Assert.AreEqual(.35f, store.Data.masterVolume); Assert.AreEqual(2.5f, loaded.mouseSensitivity); Assert.AreEqual(.6f, loaded.aimSensitivity); Assert.AreEqual(4, loaded.qualityLevel); Assert.AreEqual(1920, loaded.resolutionWidth); Assert.AreEqual(1080, loaded.resolutionHeight); Assert.IsTrue(loaded.fullscreen); Assert.AreEqual("stable input IDs", loaded.bindingOverrides); } [Test] public void MissingFieldsRetainDefaults() { File.WriteAllText(path, "{\"version\":1,\"masterVolume\":0.4}"); var loaded = new SettingsStore(path, defaults).Data; Assert.AreEqual(.4f, loaded.masterVolume); Assert.AreEqual(1, loaded.mouseSensitivity); Assert.AreEqual(720, loaded.resolutionHeight); } [Test] public void CorruptPrimaryRecoversLastCompleteBackup() { var store = new SettingsStore(path); Assert.IsTrue(store.Save(new GameSettingsData { masterVolume = .4f })); Assert.IsTrue(store.Save(new GameSettingsData { masterVolume = .7f })); File.WriteAllText(path, "{broken json"); Assert.AreEqual(.4f, new SettingsStore(path).Data.masterVolume); } [Test] public void WriteFailureAndUnsupportedVersionPreservePreviousSnapshot() { string blocker = Path.Combine(folder, "blocked"); File.WriteAllText(blocker, "not a directory"); var store = new SettingsStore(Path.Combine(blocker, "settings.json")); Assert.IsFalse(store.Save(new GameSettingsData { masterVolume = .25f })); Assert.AreEqual(1, store.Data.masterVolume); Assert.IsNotEmpty(store.Error); Assert.IsFalse(store.Save(new GameSettingsData { version = 99 })); Assert.AreEqual(1, store.Data.version); } [Test] public void InvalidNumbersAreNormalizedWithoutMutatingSource() { var data = new GameSettingsData { masterVolume = float.NaN, mouseSensitivity = float.PositiveInfinity, aimSensitivity = -5, qualityLevel = 99, resolutionWidth = -1, resolutionHeight = 0 }; var normalized = data.Normalize(6); Assert.AreEqual(1, normalized.masterVolume); Assert.AreEqual(1, normalized.mouseSensitivity); Assert.AreEqual(.1f, normalized.aimSensitivity); Assert.AreEqual(5, normalized.qualityLevel); Assert.AreEqual(1280, normalized.resolutionWidth); Assert.AreEqual(-1, data.resolutionWidth); } [Test] public void MovementPartsAndAllButtonsCanBeReboundWithoutChangingTemplateIds() { var bindings = new InputBindingService(input); Assert.AreEqual(16, bindings.Slots.Count); Assert.AreEqual(4, bindings.Slots.Count(x => x.Action.name == "Move")); Assert.IsFalse(bindings.Slots.Any(x => x.Action.name == "Look" || x.Action.bindings[x.Index].isComposite)); var target = bindings.Slots.First(x => x.Action.name == "Interact"); Guid original = target.Action.bindings[target.Index].id; Assert.AreEqual(BindingChange.Success, bindings.Rebind(target, "/j", out _)); string json = bindings.Save(); bindings.Reset(); Assert.AreEqual("/e", target.Action.bindings[target.Index].effectivePath); Assert.IsTrue(bindings.TryLoad(json)); Assert.AreEqual("/j", target.Action.bindings[target.Index].effectivePath); Assert.AreEqual(original, target.Action.bindings[target.Index].id); } [Test] public void OlderInteractionOnRSurvivesAddingReload() { var bindings = new InputBindingService(input); var interact = input.FindAction("Pilot/Interact", true); interact.ApplyBindingOverride(0, "/r"); string legacy = bindings.Save(); bindings.Reset(); Assert.IsTrue(bindings.TryLoad(legacy)); Assert.AreEqual("/r", interact.bindings[0].effectivePath); Assert.AreEqual("/t", input.FindAction("Pilot/Reload", true).bindings[0].effectivePath); } [Test] public void ConflictsReservedKeysAndMouseMotionDoNotReplaceBinding() { var bindings = new InputBindingService(input); var target = bindings.Slots.First(x => x.Action.name == "Interact"); Assert.AreEqual(BindingChange.Conflict, bindings.Rebind(target, "/w", out var conflict)); Assert.AreEqual("Move", conflict.Action.name); Assert.AreEqual(BindingChange.Reserved, bindings.Rebind(target, "/escape", out _)); Assert.AreEqual(BindingChange.Unsupported, bindings.Rebind(target, "/delta", out _)); Assert.AreEqual(BindingChange.Unsupported, bindings.Rebind(target, "/buttonSouth", out _)); Assert.AreEqual("/e", target.Action.bindings[target.Index].effectivePath); } [Test] public void InvalidOverridesDoNotDestroyExistingBindings() { var bindings = new InputBindingService(input); var target = bindings.Slots.First(x => x.Action.name == "Interact"); bindings.Rebind(target, "/middleButton", out _); Assert.IsFalse(bindings.TryLoad("{broken json")); Assert.AreEqual("/middleButton", target.Action.bindings[target.Index].effectivePath); } [Test] public void DigitZeroAndPunctuationUsePhysicalKeyboardLayoutNames() { var bindings = new InputBindingService(input); var target = bindings.Slots.First(x => x.Action.name == "Interact"); Assert.AreEqual(BindingChange.Success, bindings.Rebind(target, "/0", out _)); Assert.AreEqual(BindingChange.Success, bindings.Rebind(target, "/semicolon", out _)); Assert.AreEqual(BindingChange.Unsupported, bindings.Rebind(target, "/999", out _)); Assert.AreEqual("/semicolon", target.Action.bindings[target.Index].effectivePath); } [Test] public void SavedOverridesCannotTurnMouseLookIntoAButton() { var bindings = new InputBindingService(input); var look = input.FindAction("Pilot/Look", true); look.ApplyBindingOverride(0, "/leftButton"); string invalid = bindings.Save(); bindings.Reset(); Assert.IsFalse(bindings.TryLoad(invalid)); Assert.AreEqual("/delta", look.bindings[0].effectivePath); } [Test] public void CancellingInteractiveCaptureRestoresEnabledActions() { var bindings = new InputBindingService(input); input.Enable(); var target = bindings.Slots.First(x => x.Action.name == "Interact"); BindingChange? result = null; bindings.Begin(target, (change, _) => result = change); Assert.IsTrue(bindings.IsRebinding); Assert.IsFalse(target.Action.enabled); bindings.Cancel(); Assert.IsFalse(bindings.IsRebinding); Assert.AreEqual(BindingChange.Cancelled, result); Assert.IsTrue(input.All(x => x.enabled)); Assert.AreEqual("/e", target.Action.bindings[target.Index].effectivePath); } [TestCase(Key.J, BindingChange.Success)] [TestCase(Key.W, BindingChange.Conflict)] [TestCase(Key.Escape, BindingChange.Cancelled)] public void NativeKeyboardCaptureCompletesAndSuppressesItsMenuInput(Key key, BindingChange expected) { var previous = Keyboard.current; var keyboard = InputSystem.AddDevice("SettingsCaptureTest"); var bindings = new InputBindingService(input); try { input.Enable(); var target = bindings.Slots.First(x => x.Action.name == "Interact"); BindingChange? result = null; bindings.Begin(target, (change, _) => result = change); InputSystem.QueueStateEvent(keyboard, new UnityEngine.InputSystem.LowLevel.KeyboardState(key)); InputSystem.Update(); Assert.AreEqual(expected, result); Assert.IsFalse(bindings.IsRebinding); Assert.IsTrue(bindings.SuppressMenuInput); Assert.IsTrue(target.Action.enabled); Assert.AreEqual(key == Key.J ? "/j" : "/e", target.Action.bindings[target.Index].effectivePath); } finally { bindings.Dispose(); InputSystem.RemoveDevice(keyboard); previous?.MakeCurrent(); } } [Test] public void ApplySavesVolumeSensitivityAndBindingsTogether() { StartService(); var draft = service.Current; draft.masterVolume = .25f; draft.mouseSensitivity = 2; var target = service.Bindings.Slots.First(x => x.Action.name == "Interact"); service.Bindings.Rebind(target, "/j", out _); Assert.AreEqual(SettingsApplyResult.Saved, service.Apply(draft)); Assert.AreEqual(.25f, AudioListener.volume); Assert.AreEqual(2, service.LookSensitivity); var reloaded = new SettingsStore(path).Data; Assert.AreEqual(.25f, reloaded.masterVolume); Assert.AreEqual(2, reloaded.mouseSensitivity); StringAssert.Contains("/j", reloaded.bindingOverrides); } [TestCase(KeyCode.J, BindingChange.Success)] [TestCase(KeyCode.R, BindingChange.Conflict)] [TestCase(KeyCode.W, BindingChange.Conflict)] [TestCase(KeyCode.Alpha0, BindingChange.Success)] public void UiKeyboardCaptureUsesTheSameValidationAndCompletesOnce(KeyCode key, BindingChange expected) { var bindings = new InputBindingService(input); input.Enable(); var target = bindings.Slots.First(x => x.Action.name == "Interact"); BindingChange? result = null; int completions = 0; bindings.Begin(target, (change, _) => { result = change; completions++; }); Assert.IsTrue(bindings.CapturePath(SettingsInputCapture.KeyboardPath(key))); Assert.AreEqual(expected, result); Assert.AreEqual(1, completions); Assert.IsFalse(bindings.IsRebinding); Assert.IsTrue(target.Action.enabled); Assert.IsTrue(bindings.SuppressMenuInput); Assert.IsFalse(bindings.CapturePath("/j")); } [Test] public void DisplayPreviewExpiresDuringPauseWithoutWritingCandidate() { float time = 10; StartService(clock: () => time); var draft = service.Current; draft.resolutionWidth = 1920; draft.resolutionHeight = 1080; draft.masterVolume = .2f; Assert.AreEqual(SettingsApplyResult.ConfirmDisplay, service.Apply(draft)); Assert.IsFalse(File.Exists(path)); Assert.AreEqual(15, service.ConfirmationSeconds); Assert.AreEqual(.2f, AudioListener.volume); time += 16; Assert.IsTrue(service.Tick()); Assert.IsFalse(service.ConfirmingDisplay); Assert.AreEqual(1280, service.Current.resolutionWidth); Assert.AreEqual(1, AudioListener.volume); Assert.IsFalse(File.Exists(path)); } [Test] public void DisplayConfirmationPersistsTheEntireSnapshot() { StartService(); var draft = service.Current; draft.resolutionWidth = 1920; draft.resolutionHeight = 1080; draft.masterVolume = .6f; Assert.AreEqual(SettingsApplyResult.ConfirmDisplay, service.Apply(draft)); Assert.IsTrue(service.ConfirmDisplay()); var saved = new SettingsStore(path).Data; Assert.AreEqual(1920, saved.resolutionWidth); Assert.AreEqual(.6f, saved.masterVolume); } [Test] public void FailedApplyDoesNotChangeCurrentOrRuntimeValues() { string blocker = Path.Combine(folder, "blocked"); File.WriteAllText(blocker, "not a directory"); StartService(Path.Combine(blocker, "settings.json")); var draft = service.Current; draft.masterVolume = .2f; draft.mouseSensitivity = 4; Assert.AreEqual(SettingsApplyResult.Failed, service.Apply(draft)); Assert.AreEqual(1, service.Current.masterVolume); Assert.AreEqual(1, service.LookSensitivity); Assert.AreEqual(1, AudioListener.volume); Assert.IsNotEmpty(service.Error); } [Test] public void DiscardRestoresSavedBindingsAndProtectsCurrentFromDraftMutation() { StartService(); var draft = service.Current; draft.masterVolume = 0; var target = service.Bindings.Slots.First(x => x.Action.name == "Interact"); service.Bindings.Rebind(target, "/j", out _); service.DiscardDraft(); Assert.AreEqual("/e", target.Action.bindings[target.Index].effectivePath); Assert.AreEqual(1, service.Current.masterVolume); Assert.IsFalse(File.Exists(path)); } } }