338 lines
16 KiB
C#
338 lines
16 KiB
C#
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<float> 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, "<Keyboard>/j", out _));
|
|
string json = bindings.Save();
|
|
bindings.Reset();
|
|
Assert.AreEqual("<Keyboard>/e", target.Action.bindings[target.Index].effectivePath);
|
|
Assert.IsTrue(bindings.TryLoad(json));
|
|
Assert.AreEqual("<Keyboard>/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, "<Keyboard>/r");
|
|
string legacy = bindings.Save();
|
|
bindings.Reset();
|
|
Assert.IsTrue(bindings.TryLoad(legacy));
|
|
Assert.AreEqual("<Keyboard>/r", interact.bindings[0].effectivePath);
|
|
Assert.AreEqual("<Keyboard>/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, "<Keyboard>/w", out var conflict));
|
|
Assert.AreEqual("Move", conflict.Action.name);
|
|
Assert.AreEqual(BindingChange.Reserved, bindings.Rebind(target, "<Keyboard>/escape", out _));
|
|
Assert.AreEqual(BindingChange.Unsupported, bindings.Rebind(target, "<Mouse>/delta", out _));
|
|
Assert.AreEqual(BindingChange.Unsupported, bindings.Rebind(target, "<Gamepad>/buttonSouth", out _));
|
|
Assert.AreEqual("<Keyboard>/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, "<Mouse>/middleButton", out _);
|
|
Assert.IsFalse(bindings.TryLoad("{broken json"));
|
|
Assert.AreEqual("<Mouse>/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, "<Keyboard>/0", out _));
|
|
Assert.AreEqual(BindingChange.Success, bindings.Rebind(target, "<Keyboard>/semicolon", out _));
|
|
Assert.AreEqual(BindingChange.Unsupported, bindings.Rebind(target, "<Keyboard>/999", out _));
|
|
Assert.AreEqual("<Keyboard>/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, "<Mouse>/leftButton");
|
|
string invalid = bindings.Save();
|
|
bindings.Reset();
|
|
Assert.IsFalse(bindings.TryLoad(invalid));
|
|
Assert.AreEqual("<Mouse>/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("<Keyboard>/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<Keyboard>("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 ? "<Keyboard>/j" : "<Keyboard>/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, "<Keyboard>/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("<Keyboard>/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("<Keyboard>/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, "<Keyboard>/j", out _);
|
|
service.DiscardDraft();
|
|
Assert.AreEqual("<Keyboard>/e", target.Action.bindings[target.Index].effectivePath);
|
|
Assert.AreEqual(1, service.Current.masterVolume);
|
|
Assert.IsFalse(File.Exists(path));
|
|
}
|
|
}
|
|
}
|