1184 lines
41 KiB
C#
1184 lines
41 KiB
C#
using AcDream.App.Diagnostics;
|
|
using AcDream.App.Rendering;
|
|
using AcDream.App.Settings;
|
|
using AcDream.UI.Abstractions.Input;
|
|
using AcDream.UI.Abstractions.Panels.Settings;
|
|
using AcDream.UI.Abstractions.Settings;
|
|
using Silk.NET.Input;
|
|
|
|
namespace AcDream.App.Tests.Settings;
|
|
|
|
public sealed class RuntimeSettingsControllerTests
|
|
{
|
|
[Fact]
|
|
public void ConstructionLoadsEachBagOnceAndPublishesOneStartupSnapshot()
|
|
{
|
|
var storage = new FakeStorage
|
|
{
|
|
DisplayValue = DisplaySettings.Default with
|
|
{
|
|
VSync = false,
|
|
Quality = QualityPreset.Ultra,
|
|
},
|
|
GameplayValue = GameplaySettings.Default with
|
|
{
|
|
AutoTarget = true,
|
|
AutoRepeatAttack = true,
|
|
ViewCombatTarget = true,
|
|
},
|
|
};
|
|
var resolved = new QualitySettings(7, 18, 8, 16, true, 9);
|
|
int resolveCount = 0;
|
|
|
|
var controller = new RuntimeSettingsController(
|
|
storage,
|
|
preset =>
|
|
{
|
|
resolveCount++;
|
|
Assert.Equal(QualityPreset.Ultra, preset);
|
|
return resolved;
|
|
},
|
|
static _ => { });
|
|
|
|
Assert.Equal(1, storage.DisplayLoads);
|
|
Assert.Equal(1, storage.AudioLoads);
|
|
Assert.Equal(1, storage.GameplayLoads);
|
|
Assert.Equal(1, storage.ChatLoads);
|
|
Assert.Equal(1, storage.CharacterLoads);
|
|
Assert.Equal(1, resolveCount);
|
|
Assert.Equal("default", storage.LastLoadedCharacter);
|
|
Assert.Same(storage.DisplayValue, controller.Startup.Display);
|
|
Assert.Same(storage.AudioValue, controller.Startup.Audio);
|
|
Assert.Same(storage.GameplayValue, controller.Startup.Gameplay);
|
|
Assert.Same(storage.ChatValue, controller.Startup.Chat);
|
|
Assert.Same(storage.DefaultCharacterValue, controller.Startup.Character);
|
|
Assert.Equal(resolved, controller.Startup.Quality);
|
|
Assert.Equal(resolved, controller.ResolvedQuality);
|
|
Assert.True(controller.AutoTarget);
|
|
Assert.True(controller.AutoRepeatAttack);
|
|
Assert.True(controller.ViewCombatTarget);
|
|
Assert.Equal("default", controller.ActiveToonKey);
|
|
}
|
|
|
|
[Fact]
|
|
public void StartupApplyIsOrderedExactlyOnceAndRuntimeBindingDoesNotReplay()
|
|
{
|
|
var events = new List<string>();
|
|
var controller = CreateController(events: events);
|
|
var startup = new FakeStartupTarget(events);
|
|
|
|
controller.ApplyStartup(startup);
|
|
|
|
Assert.Equal(["startup-display", "startup-audio"], events);
|
|
Assert.Throws<InvalidOperationException>(() => controller.ApplyStartup(startup));
|
|
|
|
events.Clear();
|
|
var runtime = new FakeRuntimeTargets(events);
|
|
controller.BindRuntimeTargets(runtime);
|
|
|
|
Assert.Empty(events);
|
|
Assert.Throws<InvalidOperationException>(() =>
|
|
controller.BindRuntimeTargets(new FakeRuntimeTargets(events)));
|
|
}
|
|
|
|
[Fact]
|
|
public void ViewModelBindingReleasesOnlyItsExpectedInstance()
|
|
{
|
|
var controller = CreateController();
|
|
using InputDispatcher dispatcher = CreateDispatcher();
|
|
RuntimeSettingsViewModelBinding first = controller.CreateViewModelBinding(
|
|
new KeyBindings(),
|
|
dispatcher,
|
|
static _ => { });
|
|
|
|
first.Dispose();
|
|
RuntimeSettingsViewModelBinding second = controller.CreateViewModelBinding(
|
|
new KeyBindings(),
|
|
dispatcher,
|
|
static _ => { });
|
|
first.Dispose();
|
|
|
|
Assert.Throws<InvalidOperationException>(() =>
|
|
controller.CreateViewModelBinding(
|
|
new KeyBindings(),
|
|
dispatcher,
|
|
static _ => { }));
|
|
second.Dispose();
|
|
}
|
|
|
|
[Fact]
|
|
public void StartupRetryResumesAfterLastSuccessfulStage()
|
|
{
|
|
var displayEvents = new List<string>();
|
|
var displayController = CreateController();
|
|
var displayTarget = new FakeStartupTarget(displayEvents)
|
|
{
|
|
RemainingDisplayFailures = 1,
|
|
};
|
|
|
|
Assert.Throws<InvalidOperationException>(() =>
|
|
displayController.ApplyStartup(displayTarget));
|
|
displayController.ApplyStartup(displayTarget);
|
|
|
|
Assert.Equal(
|
|
["startup-display", "startup-display", "startup-audio"],
|
|
displayEvents);
|
|
|
|
var audioEvents = new List<string>();
|
|
var audioController = CreateController();
|
|
var audioTarget = new FakeStartupTarget(audioEvents)
|
|
{
|
|
RemainingAudioFailures = 1,
|
|
};
|
|
|
|
Assert.Throws<InvalidOperationException>(() =>
|
|
audioController.ApplyStartup(audioTarget));
|
|
audioController.ApplyStartup(audioTarget);
|
|
|
|
Assert.Equal(
|
|
["startup-display", "startup-audio", "startup-audio"],
|
|
audioEvents);
|
|
}
|
|
|
|
[Fact]
|
|
public void ConcreteStartupTargetAppliesPacingThenWindowThenPersistedFov()
|
|
{
|
|
using var profiler = new FrameProfiler();
|
|
using var pacing = new DisplayFramePacingController(
|
|
uncappedRendering: false,
|
|
profiler,
|
|
new FramePacingController(new FakeClock(), new NullWaiter()));
|
|
var surface = new FakePacingSurface
|
|
{
|
|
VSync = true,
|
|
ActiveMonitorRefreshHz = 144,
|
|
};
|
|
pacing.InitializeStartup(requestedVSync: true);
|
|
pacing.BindSurface(surface);
|
|
var cameras = new CameraController(new OrbitCamera(), new FlyCamera());
|
|
float originalFov = cameras.Orbit.FovY;
|
|
var displayWindow = new InspectingDisplayWindowTarget(display =>
|
|
{
|
|
Assert.False(pacing.RequestedVSync);
|
|
Assert.Equal(originalFov, cameras.Orbit.FovY);
|
|
Assert.Equal("1600x900", display.Resolution);
|
|
});
|
|
var target = new RuntimeSettingsStartupTargets(
|
|
displayWindow,
|
|
pacing,
|
|
cameras,
|
|
audio: null);
|
|
|
|
target.ApplyDisplay(DisplaySettings.Default with
|
|
{
|
|
Resolution = "1600x900",
|
|
VSync = false,
|
|
FieldOfView = 83f,
|
|
});
|
|
|
|
Assert.Equal(1, displayWindow.ApplyCount);
|
|
Assert.Equal(1, surface.RefreshReadCount);
|
|
Assert.Equal(new FramePacingPolicy(false, 144d), pacing.Policy);
|
|
Assert.Equal(83f * (MathF.PI / 180f), cameras.Orbit.FovY, precision: 5);
|
|
Assert.Equal(83f * (MathF.PI / 180f), cameras.Fly.FovY, precision: 5);
|
|
}
|
|
|
|
[Fact]
|
|
public void ConcreteRuntimeTargetAppliesEveryQualityDimensionInOrder()
|
|
{
|
|
var events = new List<string>();
|
|
var qualityTarget = new RecordingQualityApplicationTarget(events);
|
|
var displayTarget = new InspectingDisplayWindowTarget(
|
|
_ => events.Add("display"));
|
|
var uiTarget = new RecordingUiLockTarget(events);
|
|
var target = new RuntimeSettingsTargets(
|
|
displayTarget,
|
|
qualityTarget,
|
|
uiTarget,
|
|
static _ => { });
|
|
var quality = new QualitySettings(6, 17, 4, 12, true, 7);
|
|
|
|
target.ApplyQuality(quality);
|
|
|
|
Assert.Equal(
|
|
[
|
|
"a2c:True",
|
|
"aniso:12",
|
|
"range:6:17",
|
|
"stream:6:17",
|
|
"budget:7",
|
|
],
|
|
events);
|
|
Assert.Equal(quality, qualityTarget.Observed);
|
|
}
|
|
|
|
[Fact]
|
|
public void ConcreteRuntimeQualityTargetStopsAtTheThrowingStep()
|
|
{
|
|
string[] allSteps =
|
|
[
|
|
"a2c",
|
|
"aniso",
|
|
"range",
|
|
"stream",
|
|
"budget",
|
|
];
|
|
|
|
for (int failureIndex = 0; failureIndex < allSteps.Length; failureIndex++)
|
|
{
|
|
var events = new List<string>();
|
|
var target = new RuntimeSettingsTargets(
|
|
new InspectingDisplayWindowTarget(static _ => { }),
|
|
new FailingQualityApplicationTarget(events, failureIndex),
|
|
new RecordingUiLockTarget(events),
|
|
static _ => { });
|
|
|
|
Assert.Throws<InvalidOperationException>(() =>
|
|
target.ApplyQuality(QualitySettings.From(QualityPreset.High)));
|
|
Assert.Equal(allSteps[..(failureIndex + 1)], events);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void SettingsViewModelSavePreservesSectionAndTargetOrder()
|
|
{
|
|
var events = new List<string>();
|
|
var storage = new FakeStorage(events);
|
|
var resolved = new QualitySettings(5, 15, 4, 16, true, 6);
|
|
var controller = new RuntimeSettingsController(
|
|
storage,
|
|
_ => resolved,
|
|
static _ => { });
|
|
storage.ClearEvents();
|
|
var targets = new FakeRuntimeTargets(events);
|
|
controller.BindRuntimeTargets(targets);
|
|
using InputDispatcher dispatcher = CreateDispatcher();
|
|
SettingsVM viewModel = controller.CreateViewModel(
|
|
new KeyBindings(),
|
|
dispatcher,
|
|
_ => events.Add("save-bindings"));
|
|
|
|
viewModel.SetDisplay(viewModel.DisplayDraft with
|
|
{
|
|
Resolution = "1920x1080",
|
|
Quality = QualityPreset.Ultra,
|
|
});
|
|
viewModel.SetAudio(viewModel.AudioDraft with { Master = 0.25f });
|
|
viewModel.SetGameplay(viewModel.GameplayDraft with { LockUI = true });
|
|
viewModel.SetChat(viewModel.ChatDraft with { ShowTimestamps = true });
|
|
viewModel.SetCharacter(viewModel.CharacterDraft with { AutoAttack = true });
|
|
|
|
viewModel.Save();
|
|
|
|
Assert.Equal(
|
|
[
|
|
"save-bindings",
|
|
"save-display",
|
|
"target-display",
|
|
"target-quality",
|
|
"save-audio",
|
|
"save-gameplay",
|
|
"target-ui-lock:True",
|
|
"save-chat",
|
|
"save-character:default",
|
|
],
|
|
events);
|
|
Assert.Equal("1920x1080", controller.Display.Resolution);
|
|
Assert.Equal(0.25f, controller.Audio.Master);
|
|
Assert.True(controller.Gameplay.LockUI);
|
|
Assert.True(controller.Chat.ShowTimestamps);
|
|
Assert.True(controller.Character.AutoAttack);
|
|
Assert.Equal(resolved, controller.ResolvedQuality);
|
|
}
|
|
|
|
[Fact]
|
|
public void DraftPreviewAndExternalCommandsShareCanonicalState()
|
|
{
|
|
var events = new List<string>();
|
|
var storage = new FakeStorage(events);
|
|
var controller = CreateController(storage, events: events);
|
|
storage.ClearEvents();
|
|
var targets = new FakeRuntimeTargets(events);
|
|
controller.BindRuntimeTargets(targets);
|
|
using InputDispatcher dispatcher = CreateDispatcher();
|
|
SettingsVM viewModel = controller.CreateViewModel(
|
|
new KeyBindings(),
|
|
dispatcher,
|
|
static _ => { });
|
|
viewModel.SetDisplay(viewModel.DisplayDraft with
|
|
{
|
|
FieldOfView = 91f,
|
|
ParticleRange = ParticleRange.Retail,
|
|
});
|
|
viewModel.SetAudio(viewModel.AudioDraft with { Sfx = 0.33f });
|
|
viewModel.SetGameplay(viewModel.GameplayDraft with { AutoTarget = true });
|
|
|
|
Assert.True(controller.HasDraftPreview);
|
|
Assert.Equal(91f, controller.DisplayPreview.FieldOfView);
|
|
Assert.Equal(ParticleRange.Retail, controller.DisplayPreview.ParticleRange);
|
|
Assert.Equal(0.33f, controller.AudioPreview.Sfx);
|
|
|
|
controller.ToggleFrameRate();
|
|
controller.SetUiLocked(true);
|
|
controller.SetAcceptLootPermits(true);
|
|
|
|
Assert.True(controller.Display.ShowFps);
|
|
Assert.True(controller.DisplayPreview.ShowFps);
|
|
Assert.Equal(91f, controller.DisplayPreview.FieldOfView);
|
|
Assert.True(controller.Gameplay.LockUI);
|
|
Assert.True(controller.Gameplay.AcceptLootPermits);
|
|
Assert.True(viewModel.GameplayDraft.AutoTarget);
|
|
Assert.True(viewModel.GameplayDraft.LockUI);
|
|
Assert.True(viewModel.GameplayDraft.AcceptLootPermits);
|
|
Assert.Contains("target-ui-lock:True", events);
|
|
Assert.Equal(2, storage.GameplaySaves);
|
|
Assert.Equal(1, storage.DisplaySaves);
|
|
}
|
|
|
|
[Fact]
|
|
public void DraftCancelRestoresPreviewAndCombatTogglePreservesUnrelatedDrafts()
|
|
{
|
|
var storage = new FakeStorage();
|
|
var controller = CreateController(storage);
|
|
using InputDispatcher dispatcher = CreateDispatcher();
|
|
SettingsVM viewModel = controller.CreateViewModel(
|
|
new KeyBindings(),
|
|
dispatcher,
|
|
static _ => { });
|
|
viewModel.SetDisplay(viewModel.DisplayDraft with { FieldOfView = 99f });
|
|
viewModel.SetGameplay(viewModel.GameplayDraft with
|
|
{
|
|
ShowTooltips = false,
|
|
CoordinatesOnRadar = false,
|
|
});
|
|
|
|
controller.SetCombatGameplay(controller.Gameplay with { AutoTarget = false });
|
|
controller.SetUiLocked(true);
|
|
controller.SetAcceptLootPermits(true);
|
|
|
|
Assert.False(controller.Gameplay.AutoTarget);
|
|
Assert.False(viewModel.GameplayDraft.AutoTarget);
|
|
Assert.False(viewModel.GameplayDraft.ShowTooltips);
|
|
Assert.False(viewModel.GameplayDraft.CoordinatesOnRadar);
|
|
Assert.True(viewModel.GameplayDraft.LockUI);
|
|
Assert.True(viewModel.GameplayDraft.AcceptLootPermits);
|
|
Assert.Equal(99f, controller.DisplayPreview.FieldOfView);
|
|
|
|
viewModel.Cancel();
|
|
|
|
Assert.Equal(controller.Display.FieldOfView, controller.DisplayPreview.FieldOfView);
|
|
Assert.Equal(controller.Gameplay.ShowTooltips, viewModel.GameplayDraft.ShowTooltips);
|
|
Assert.Equal(
|
|
controller.Gameplay.CoordinatesOnRadar,
|
|
viewModel.GameplayDraft.CoordinatesOnRadar);
|
|
Assert.False(viewModel.GameplayDraft.AutoTarget);
|
|
Assert.True(viewModel.GameplayDraft.LockUI);
|
|
Assert.True(viewModel.GameplayDraft.AcceptLootPermits);
|
|
|
|
viewModel.SetGameplay(viewModel.GameplayDraft with { ShowHelm = false });
|
|
viewModel.Save();
|
|
|
|
Assert.False(storage.GameplayValue.AutoTarget);
|
|
Assert.True(storage.GameplayValue.LockUI);
|
|
Assert.True(storage.GameplayValue.AcceptLootPermits);
|
|
Assert.False(storage.GameplayValue.ShowHelm);
|
|
}
|
|
|
|
[Fact]
|
|
public void FailedExternalGameplayPersistenceDoesNotPromoteViewModelBaseline()
|
|
{
|
|
var storage = new FakeStorage { ThrowOnGameplaySave = true };
|
|
var logs = new List<string>();
|
|
var controller = new RuntimeSettingsController(
|
|
storage,
|
|
static preset => QualitySettings.From(preset),
|
|
logs.Add);
|
|
using InputDispatcher dispatcher = CreateDispatcher();
|
|
SettingsVM viewModel = controller.CreateViewModel(
|
|
new KeyBindings(),
|
|
dispatcher,
|
|
static _ => { });
|
|
|
|
controller.SetUiLocked(true);
|
|
controller.SetCombatGameplay(controller.Gameplay with { AutoTarget = false });
|
|
Assert.Throws<IOException>(() => controller.SetAcceptLootPermits(true));
|
|
|
|
Assert.True(viewModel.GameplayDraft.LockUI);
|
|
Assert.False(viewModel.GameplayDraft.AutoTarget);
|
|
Assert.True(viewModel.GameplayDraft.AcceptLootPermits);
|
|
|
|
viewModel.Cancel();
|
|
|
|
Assert.Equal(GameplaySettings.Default.LockUI, viewModel.GameplayDraft.LockUI);
|
|
Assert.Equal(
|
|
GameplaySettings.Default.AutoTarget,
|
|
viewModel.GameplayDraft.AutoTarget);
|
|
Assert.Equal(
|
|
GameplaySettings.Default.AcceptLootPermits,
|
|
viewModel.GameplayDraft.AcceptLootPermits);
|
|
Assert.Contains(logs, line =>
|
|
line.Contains("radar lock save failed", StringComparison.Ordinal));
|
|
Assert.Contains(logs, line =>
|
|
line.Contains("combat option save failed", StringComparison.Ordinal));
|
|
}
|
|
|
|
[Fact]
|
|
public void CharacterContextLoadsWithoutViewModelAndSynchronizesWhenBound()
|
|
{
|
|
var storage = new FakeStorage();
|
|
storage.Characters["Alice"] = CharacterSettings.Default with
|
|
{
|
|
DefaultChatChannel = "Trade",
|
|
};
|
|
storage.Characters["Bob"] = CharacterSettings.Default with
|
|
{
|
|
ConfirmSalvage = false,
|
|
};
|
|
var controller = CreateController(storage);
|
|
|
|
controller.SetActiveCharacter("Alice");
|
|
controller.LoadCharacterContext("Alice");
|
|
|
|
Assert.Equal("Alice", controller.ActiveToonKey);
|
|
Assert.Equal("Trade", controller.Character.DefaultChatChannel);
|
|
|
|
using InputDispatcher dispatcher = CreateDispatcher();
|
|
SettingsVM viewModel = controller.CreateViewModel(
|
|
new KeyBindings(),
|
|
dispatcher,
|
|
static _ => { });
|
|
controller.LoadCharacterContext("Bob");
|
|
|
|
Assert.Equal("Bob", controller.ActiveToonKey);
|
|
Assert.False(viewModel.CharacterDraft.ConfirmSalvage);
|
|
|
|
controller.RestoreDefaultCharacterContext();
|
|
controller.ResetActiveCharacterKey();
|
|
|
|
Assert.Equal("default", controller.ActiveToonKey);
|
|
Assert.Same(storage.DefaultCharacterValue, controller.Character);
|
|
Assert.Same(storage.DefaultCharacterValue, viewModel.CharacterDraft);
|
|
}
|
|
|
|
[Fact]
|
|
public void CharacterSaveUsesActiveToonAndDefaultSaveBecomesResetContext()
|
|
{
|
|
var storage = new FakeStorage();
|
|
var controller = CreateController(storage);
|
|
using InputDispatcher dispatcher = CreateDispatcher();
|
|
SettingsVM viewModel = controller.CreateViewModel(
|
|
new KeyBindings(),
|
|
dispatcher,
|
|
static _ => { });
|
|
var newDefault = CharacterSettings.Default with { AutoAttack = true };
|
|
viewModel.SetCharacter(newDefault);
|
|
viewModel.Save();
|
|
|
|
controller.LoadCharacterContext("Alice");
|
|
var alice = CharacterSettings.Default with { DefaultChatChannel = "Trade" };
|
|
viewModel.SetCharacter(alice);
|
|
viewModel.Save();
|
|
|
|
Assert.Same(newDefault, storage.Characters["default"]);
|
|
Assert.Same(alice, storage.Characters["Alice"]);
|
|
controller.RestoreDefaultCharacterContext();
|
|
Assert.Same(newDefault, controller.Character);
|
|
Assert.Same(newDefault, viewModel.CharacterDraft);
|
|
}
|
|
|
|
[Fact]
|
|
public void ViewModelAndRuntimeTargetLoansCanBeWithdrawnAndReboundPrecisely()
|
|
{
|
|
var events = new List<string>();
|
|
var controller = CreateController(events: events);
|
|
using InputDispatcher dispatcher = CreateDispatcher();
|
|
SettingsVM viewModel = controller.CreateViewModel(
|
|
new KeyBindings(),
|
|
dispatcher,
|
|
static _ => { });
|
|
SettingsVM foreign = CreateStandaloneViewModel(dispatcher);
|
|
|
|
controller.UnbindViewModel(foreign);
|
|
Assert.True(controller.HasDraftPreview);
|
|
Assert.Throws<InvalidOperationException>(() =>
|
|
controller.CreateViewModel(
|
|
new KeyBindings(),
|
|
dispatcher,
|
|
static _ => { }));
|
|
|
|
controller.UnbindViewModel(viewModel);
|
|
Assert.False(controller.HasDraftPreview);
|
|
controller.CreateViewModel(
|
|
new KeyBindings(),
|
|
dispatcher,
|
|
static _ => { });
|
|
|
|
var first = new FakeRuntimeTargets(events);
|
|
controller.BindRuntimeTargets(first);
|
|
controller.UnbindRuntimeTargets();
|
|
var second = new FakeRuntimeTargets(events);
|
|
controller.BindRuntimeTargets(second);
|
|
controller.SetUiLocked(true);
|
|
|
|
Assert.Equal(0, first.UiLockCalls);
|
|
Assert.Equal(1, second.UiLockCalls);
|
|
}
|
|
|
|
[Fact]
|
|
public void DisplayPersistenceFailureDoesNotPublishStateOrTargets()
|
|
{
|
|
var events = new List<string>();
|
|
var storage = new FakeStorage(events) { ThrowOnDisplaySave = true };
|
|
var logs = new List<string>();
|
|
var controller = new RuntimeSettingsController(
|
|
storage,
|
|
static preset => QualitySettings.From(preset),
|
|
logs.Add);
|
|
storage.ClearEvents();
|
|
controller.BindRuntimeTargets(new FakeRuntimeTargets(events));
|
|
using InputDispatcher dispatcher = CreateDispatcher();
|
|
SettingsVM viewModel = controller.CreateViewModel(
|
|
new KeyBindings(),
|
|
dispatcher,
|
|
static _ => { });
|
|
DisplaySettings original = controller.Display;
|
|
viewModel.SetDisplay(viewModel.DisplayDraft with
|
|
{
|
|
Resolution = "2560x1440",
|
|
Quality = QualityPreset.Ultra,
|
|
});
|
|
|
|
viewModel.Save();
|
|
|
|
Assert.Same(original, controller.Display);
|
|
Assert.DoesNotContain("target-display", events);
|
|
Assert.DoesNotContain("target-quality", events);
|
|
Assert.Contains(logs, line => line.Contains("display save failed", StringComparison.Ordinal));
|
|
}
|
|
|
|
[Fact]
|
|
public void DisplayTargetFailurePreservesEstablishedStoreThenPublishBoundary()
|
|
{
|
|
var events = new List<string>();
|
|
var storage = new FakeStorage(events);
|
|
var logs = new List<string>();
|
|
var controller = new RuntimeSettingsController(
|
|
storage,
|
|
static preset => QualitySettings.From(preset),
|
|
logs.Add);
|
|
storage.ClearEvents();
|
|
controller.BindRuntimeTargets(new FakeRuntimeTargets(events)
|
|
{
|
|
ThrowOnDisplay = true,
|
|
});
|
|
using InputDispatcher dispatcher = CreateDispatcher();
|
|
SettingsVM viewModel = controller.CreateViewModel(
|
|
new KeyBindings(),
|
|
dispatcher,
|
|
static _ => { });
|
|
DisplaySettings original = controller.Display;
|
|
viewModel.SetDisplay(viewModel.DisplayDraft with
|
|
{
|
|
Resolution = "3840x2160",
|
|
Quality = QualityPreset.Ultra,
|
|
});
|
|
|
|
viewModel.Save();
|
|
|
|
Assert.Equal(1, storage.DisplaySaves);
|
|
Assert.Same(original, controller.Display);
|
|
Assert.DoesNotContain("target-quality", events);
|
|
Assert.Contains(logs, line => line.Contains("display save failed", StringComparison.Ordinal));
|
|
}
|
|
|
|
[Fact]
|
|
public void NonDisplayPersistenceFailuresContinueAndPreserveControllerState()
|
|
{
|
|
var storage = new FakeStorage
|
|
{
|
|
ThrowOnAudioSave = true,
|
|
ThrowOnGameplaySave = true,
|
|
ThrowOnChatSave = true,
|
|
ThrowOnCharacterSave = true,
|
|
};
|
|
var logs = new List<string>();
|
|
var controller = new RuntimeSettingsController(
|
|
storage,
|
|
static preset => QualitySettings.From(preset),
|
|
logs.Add);
|
|
AudioSettings originalAudio = controller.Audio;
|
|
GameplaySettings originalGameplay = controller.Gameplay;
|
|
ChatSettings originalChat = controller.Chat;
|
|
CharacterSettings originalCharacter = controller.Character;
|
|
using InputDispatcher dispatcher = CreateDispatcher();
|
|
SettingsVM viewModel = controller.CreateViewModel(
|
|
new KeyBindings(),
|
|
dispatcher,
|
|
static _ => { });
|
|
viewModel.SetAudio(viewModel.AudioDraft with { Master = 0.1f });
|
|
viewModel.SetGameplay(viewModel.GameplayDraft with { LockUI = true });
|
|
viewModel.SetChat(viewModel.ChatDraft with { ShowTimestamps = true });
|
|
viewModel.SetCharacter(viewModel.CharacterDraft with { AutoAttack = true });
|
|
|
|
viewModel.Save();
|
|
|
|
Assert.Same(originalAudio, controller.Audio);
|
|
Assert.Same(originalGameplay, controller.Gameplay);
|
|
Assert.Same(originalChat, controller.Chat);
|
|
Assert.Same(originalCharacter, controller.Character);
|
|
Assert.Contains(logs, line => line.Contains("audio save failed", StringComparison.Ordinal));
|
|
Assert.Contains(logs, line => line.Contains("gameplay save failed", StringComparison.Ordinal));
|
|
Assert.Contains(logs, line => line.Contains("chat save failed", StringComparison.Ordinal));
|
|
Assert.Contains(logs, line => line.Contains("character save failed", StringComparison.Ordinal));
|
|
}
|
|
|
|
[Fact]
|
|
public void MsaaChangeIsRestartRequiredWhileOtherQualityStateAdvances()
|
|
{
|
|
var logs = new List<string>();
|
|
var events = new List<string>();
|
|
var controller = new RuntimeSettingsController(
|
|
new FakeStorage(),
|
|
static preset => QualitySettings.From(preset),
|
|
logs.Add);
|
|
controller.BindRuntimeTargets(new FakeRuntimeTargets(events));
|
|
|
|
controller.ReapplyQualityPreset(QualityPreset.Low);
|
|
|
|
Assert.Equal(QualitySettings.From(QualityPreset.Low), controller.ResolvedQuality);
|
|
Assert.Equal(["target-quality"], events);
|
|
Assert.Contains(logs, line =>
|
|
line.Contains("MSAA samples change (4 -> 0) requires a restart", StringComparison.Ordinal));
|
|
}
|
|
|
|
[Fact]
|
|
public void QualityTargetFailurePublishesResolvedQualityThenPropagates()
|
|
{
|
|
var events = new List<string>();
|
|
var controller = CreateController(events: events);
|
|
controller.BindRuntimeTargets(new FakeRuntimeTargets(events)
|
|
{
|
|
ThrowOnQuality = true,
|
|
});
|
|
QualitySettings requested = QualitySettings.From(QualityPreset.Ultra);
|
|
|
|
Assert.Throws<InvalidOperationException>(() =>
|
|
controller.ReapplyQualityPreset(QualityPreset.Ultra));
|
|
|
|
Assert.Equal(requested, controller.ResolvedQuality);
|
|
Assert.Equal(["target-quality"], events);
|
|
}
|
|
|
|
[Fact]
|
|
public void UiLockTargetFailureCanRetryTheSameRequestedValue()
|
|
{
|
|
var events = new List<string>();
|
|
var storage = new FakeStorage(events);
|
|
var controller = CreateController(storage, events);
|
|
storage.ClearEvents();
|
|
var targets = new FakeRuntimeTargets(events)
|
|
{
|
|
RemainingUiLockFailures = 1,
|
|
};
|
|
controller.BindRuntimeTargets(targets);
|
|
using InputDispatcher dispatcher = CreateDispatcher();
|
|
SettingsVM viewModel = controller.CreateViewModel(
|
|
new KeyBindings(),
|
|
dispatcher,
|
|
static _ => { });
|
|
|
|
Assert.Throws<InvalidOperationException>(() => controller.SetUiLocked(true));
|
|
|
|
Assert.True(controller.Gameplay.LockUI);
|
|
Assert.Equal(GameplaySettings.Default.LockUI, viewModel.GameplayDraft.LockUI);
|
|
Assert.Equal(0, storage.GameplaySaves);
|
|
Assert.Equal(["target-ui-lock:True"], events);
|
|
|
|
events.Clear();
|
|
controller.SetUiLocked(true);
|
|
|
|
Assert.True(controller.Gameplay.LockUI);
|
|
Assert.True(viewModel.GameplayDraft.LockUI);
|
|
viewModel.Cancel();
|
|
Assert.True(viewModel.GameplayDraft.LockUI);
|
|
Assert.Equal(1, storage.GameplaySaves);
|
|
Assert.Equal(["target-ui-lock:True", "save-gameplay"], events);
|
|
}
|
|
|
|
[Fact]
|
|
public void UiLockPersistenceFailureCanRetryTheSameRequestedValue()
|
|
{
|
|
var events = new List<string>();
|
|
var storage = new FakeStorage(events)
|
|
{
|
|
RemainingGameplaySaveFailures = 1,
|
|
};
|
|
var logs = new List<string>();
|
|
var controller = new RuntimeSettingsController(
|
|
storage,
|
|
static preset => QualitySettings.From(preset),
|
|
logs.Add);
|
|
storage.ClearEvents();
|
|
controller.BindRuntimeTargets(new FakeRuntimeTargets(events));
|
|
using InputDispatcher dispatcher = CreateDispatcher();
|
|
SettingsVM viewModel = controller.CreateViewModel(
|
|
new KeyBindings(),
|
|
dispatcher,
|
|
static _ => { });
|
|
|
|
controller.SetUiLocked(true);
|
|
|
|
Assert.True(controller.Gameplay.LockUI);
|
|
Assert.True(viewModel.GameplayDraft.LockUI);
|
|
Assert.Equal(1, storage.GameplaySaves);
|
|
Assert.Contains(logs, line =>
|
|
line.Contains("radar lock save failed", StringComparison.Ordinal));
|
|
|
|
events.Clear();
|
|
controller.SetUiLocked(true);
|
|
|
|
Assert.True(controller.Gameplay.LockUI);
|
|
viewModel.Cancel();
|
|
Assert.True(viewModel.GameplayDraft.LockUI);
|
|
Assert.Equal(2, storage.GameplaySaves);
|
|
Assert.Equal(["target-ui-lock:True", "save-gameplay"], events);
|
|
}
|
|
|
|
[Fact]
|
|
public void UnbindingLoansStopsCallsButStateAndPersistenceContinue()
|
|
{
|
|
var events = new List<string>();
|
|
var storage = new FakeStorage(events);
|
|
var controller = CreateController(storage, events: events);
|
|
storage.ClearEvents();
|
|
controller.BindRuntimeTargets(new FakeRuntimeTargets(events));
|
|
controller.UnbindRuntimeTargets();
|
|
|
|
controller.SetUiLocked(true);
|
|
controller.ReapplyQualityPreset(QualityPreset.Ultra);
|
|
|
|
Assert.True(controller.Gameplay.LockUI);
|
|
Assert.Equal(
|
|
QualitySettings.From(QualityPreset.Ultra),
|
|
controller.ResolvedQuality);
|
|
Assert.DoesNotContain(events, value => value.StartsWith("target-", StringComparison.Ordinal));
|
|
Assert.Equal(1, storage.GameplaySaves);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("1920x1080", true, 1920, 1080)]
|
|
[InlineData(" 800x600 ", true, 800, 600)]
|
|
[InlineData("", false, 0, 0)]
|
|
[InlineData("1920", false, 0, 0)]
|
|
[InlineData("0x1080", false, 0, 1080)]
|
|
[InlineData("1920x-1", false, 1920, -1)]
|
|
public void ResolutionParserMatchesWindowTargetPolicy(
|
|
string spec,
|
|
bool expected,
|
|
int expectedWidth,
|
|
int expectedHeight)
|
|
{
|
|
bool parsed = SilkRuntimeDisplayWindowTarget.TryParseResolution(
|
|
spec,
|
|
out int width,
|
|
out int height);
|
|
|
|
Assert.Equal(expected, parsed);
|
|
Assert.Equal(expectedWidth, width);
|
|
Assert.Equal(expectedHeight, height);
|
|
}
|
|
|
|
private static RuntimeSettingsController CreateController(
|
|
FakeStorage? storage = null,
|
|
List<string>? events = null)
|
|
{
|
|
storage ??= new FakeStorage(events);
|
|
return new RuntimeSettingsController(
|
|
storage,
|
|
static preset => QualitySettings.From(preset),
|
|
static _ => { });
|
|
}
|
|
|
|
private static InputDispatcher CreateDispatcher()
|
|
{
|
|
InputDispatcher dispatcher = InputDispatcher.CreateDetached(
|
|
new NullKeyboardSource(),
|
|
new NullMouseSource(),
|
|
new KeyBindings());
|
|
dispatcher.Attach();
|
|
return dispatcher;
|
|
}
|
|
|
|
private static SettingsVM CreateStandaloneViewModel(InputDispatcher dispatcher) =>
|
|
new(
|
|
new KeyBindings(),
|
|
dispatcher,
|
|
static _ => { },
|
|
DisplaySettings.Default,
|
|
static _ => { },
|
|
AudioSettings.Default,
|
|
static _ => { },
|
|
GameplaySettings.Default,
|
|
static _ => { },
|
|
ChatSettings.Default,
|
|
static _ => { },
|
|
CharacterSettings.Default,
|
|
static _ => { });
|
|
|
|
private sealed class FakeStartupTarget(List<string> events)
|
|
: IRuntimeSettingsStartupTarget
|
|
{
|
|
public int RemainingDisplayFailures { get; set; }
|
|
|
|
public int RemainingAudioFailures { get; set; }
|
|
|
|
public void ApplyDisplay(DisplaySettings display)
|
|
{
|
|
events.Add("startup-display");
|
|
|
|
if (RemainingDisplayFailures > 0)
|
|
{
|
|
RemainingDisplayFailures--;
|
|
throw new InvalidOperationException("display startup failed");
|
|
}
|
|
}
|
|
|
|
public void ApplyAudio(AudioSettings audio)
|
|
{
|
|
events.Add("startup-audio");
|
|
if (RemainingAudioFailures > 0)
|
|
{
|
|
RemainingAudioFailures--;
|
|
throw new InvalidOperationException("audio startup failed");
|
|
}
|
|
}
|
|
}
|
|
|
|
private sealed class FakeRuntimeTargets(List<string> events)
|
|
: IRuntimeSettingsTargets
|
|
{
|
|
public bool ThrowOnDisplay { get; init; }
|
|
|
|
public bool ThrowOnQuality { get; init; }
|
|
|
|
public int RemainingUiLockFailures { get; set; }
|
|
|
|
public int UiLockCalls { get; private set; }
|
|
|
|
public void ApplyDisplayWindowState(DisplaySettings display)
|
|
{
|
|
events.Add("target-display");
|
|
if (ThrowOnDisplay)
|
|
throw new InvalidOperationException("display target failed");
|
|
}
|
|
|
|
public void ApplyQuality(QualitySettings quality)
|
|
{
|
|
events.Add("target-quality");
|
|
if (ThrowOnQuality)
|
|
throw new InvalidOperationException("quality target failed");
|
|
}
|
|
|
|
public void ApplyUiLock(bool locked)
|
|
{
|
|
UiLockCalls++;
|
|
events.Add($"target-ui-lock:{locked}");
|
|
if (RemainingUiLockFailures > 0)
|
|
{
|
|
RemainingUiLockFailures--;
|
|
throw new InvalidOperationException("UI-lock target failed");
|
|
}
|
|
}
|
|
}
|
|
|
|
private sealed class InspectingDisplayWindowTarget(
|
|
Action<DisplaySettings> apply)
|
|
: IRuntimeDisplayWindowTarget
|
|
{
|
|
public int ApplyCount { get; private set; }
|
|
|
|
public void Apply(DisplaySettings display)
|
|
{
|
|
ApplyCount++;
|
|
apply(display);
|
|
}
|
|
}
|
|
|
|
private sealed class RecordingQualityApplicationTarget(List<string> events)
|
|
: IRuntimeQualityApplicationTarget
|
|
{
|
|
private bool _alphaToCoverage;
|
|
private int _anisotropic;
|
|
private int _near;
|
|
private int _far;
|
|
private int _streamNear;
|
|
private int _streamFar;
|
|
private int _budget;
|
|
|
|
public QualitySettings Observed => new(
|
|
_near,
|
|
_far,
|
|
4,
|
|
_anisotropic,
|
|
_alphaToCoverage,
|
|
_budget);
|
|
|
|
public void SetAlphaToCoverage(bool enabled)
|
|
{
|
|
_alphaToCoverage = enabled;
|
|
events.Add($"a2c:{enabled}");
|
|
}
|
|
|
|
public void SetAnisotropic(int level)
|
|
{
|
|
_anisotropic = level;
|
|
events.Add($"aniso:{level}");
|
|
}
|
|
|
|
public void PublishRenderRange(int nearRadius, int farRadius)
|
|
{
|
|
_near = nearRadius;
|
|
_far = farRadius;
|
|
events.Add($"range:{nearRadius}:{farRadius}");
|
|
}
|
|
|
|
public void ReconfigureStreamingRadii(int nearRadius, int farRadius)
|
|
{
|
|
_streamNear = nearRadius;
|
|
_streamFar = farRadius;
|
|
events.Add($"stream:{nearRadius}:{farRadius}");
|
|
}
|
|
|
|
public void SetCompletionBudget(int maxCompletionsPerFrame)
|
|
{
|
|
Assert.Equal(_near, _streamNear);
|
|
Assert.Equal(_far, _streamFar);
|
|
_budget = maxCompletionsPerFrame;
|
|
events.Add($"budget:{maxCompletionsPerFrame}");
|
|
}
|
|
}
|
|
|
|
private sealed class FailingQualityApplicationTarget(
|
|
List<string> events,
|
|
int failureIndex)
|
|
: IRuntimeQualityApplicationTarget
|
|
{
|
|
private int _step;
|
|
|
|
public void SetAlphaToCoverage(bool enabled) => Record("a2c");
|
|
|
|
public void SetAnisotropic(int level) => Record("aniso");
|
|
|
|
public void PublishRenderRange(int nearRadius, int farRadius) => Record("range");
|
|
|
|
public void ReconfigureStreamingRadii(int nearRadius, int farRadius) =>
|
|
Record("stream");
|
|
|
|
public void SetCompletionBudget(int maxCompletionsPerFrame) => Record("budget");
|
|
|
|
private void Record(string step)
|
|
{
|
|
events.Add(step);
|
|
if (_step++ == failureIndex)
|
|
throw new InvalidOperationException($"{step} failed");
|
|
}
|
|
}
|
|
|
|
private sealed class RecordingUiLockTarget(List<string> events)
|
|
: IRuntimeUiLockTarget
|
|
{
|
|
public void Apply(bool locked) => events.Add($"ui:{locked}");
|
|
}
|
|
|
|
private sealed class FakeStorage(List<string>? events = null)
|
|
: IRuntimeSettingsStorage
|
|
{
|
|
private readonly List<string> _events = events ?? [];
|
|
|
|
public DisplaySettings DisplayValue { get; set; } = DisplaySettings.Default;
|
|
|
|
public AudioSettings AudioValue { get; set; } = AudioSettings.Default;
|
|
|
|
public GameplaySettings GameplayValue { get; set; } = GameplaySettings.Default;
|
|
|
|
public ChatSettings ChatValue { get; set; } = ChatSettings.Default;
|
|
|
|
public CharacterSettings DefaultCharacterValue { get; set; } =
|
|
CharacterSettings.Default;
|
|
|
|
public Dictionary<string, CharacterSettings> Characters { get; } =
|
|
new(StringComparer.OrdinalIgnoreCase);
|
|
|
|
public SettingsStore? LayoutStore => null;
|
|
|
|
public string Location => "memory://settings";
|
|
|
|
public int DisplayLoads { get; private set; }
|
|
|
|
public int AudioLoads { get; private set; }
|
|
|
|
public int GameplayLoads { get; private set; }
|
|
|
|
public int ChatLoads { get; private set; }
|
|
|
|
public int CharacterLoads { get; private set; }
|
|
|
|
public int DisplaySaves { get; private set; }
|
|
|
|
public int GameplaySaves { get; private set; }
|
|
|
|
public string? LastLoadedCharacter { get; private set; }
|
|
|
|
public bool ThrowOnDisplaySave { get; init; }
|
|
|
|
public bool ThrowOnAudioSave { get; init; }
|
|
|
|
public bool ThrowOnGameplaySave { get; init; }
|
|
|
|
public int RemainingGameplaySaveFailures { get; set; }
|
|
|
|
public bool ThrowOnChatSave { get; init; }
|
|
|
|
public bool ThrowOnCharacterSave { get; init; }
|
|
|
|
public DisplaySettings LoadDisplay()
|
|
{
|
|
DisplayLoads++;
|
|
return DisplayValue;
|
|
}
|
|
|
|
public AudioSettings LoadAudio()
|
|
{
|
|
AudioLoads++;
|
|
return AudioValue;
|
|
}
|
|
|
|
public GameplaySettings LoadGameplay()
|
|
{
|
|
GameplayLoads++;
|
|
return GameplayValue;
|
|
}
|
|
|
|
public ChatSettings LoadChat()
|
|
{
|
|
ChatLoads++;
|
|
return ChatValue;
|
|
}
|
|
|
|
public CharacterSettings LoadCharacter(string toonKey)
|
|
{
|
|
CharacterLoads++;
|
|
LastLoadedCharacter = toonKey;
|
|
return Characters.TryGetValue(toonKey, out CharacterSettings? value)
|
|
? value
|
|
: DefaultCharacterValue;
|
|
}
|
|
|
|
public void SaveDisplay(DisplaySettings display)
|
|
{
|
|
DisplaySaves++;
|
|
_events.Add("save-display");
|
|
if (ThrowOnDisplaySave)
|
|
throw new IOException("display persistence failed");
|
|
DisplayValue = display;
|
|
}
|
|
|
|
public void SaveAudio(AudioSettings audio)
|
|
{
|
|
_events.Add("save-audio");
|
|
if (ThrowOnAudioSave)
|
|
throw new IOException("audio persistence failed");
|
|
AudioValue = audio;
|
|
}
|
|
|
|
public void SaveGameplay(GameplaySettings gameplay)
|
|
{
|
|
GameplaySaves++;
|
|
_events.Add("save-gameplay");
|
|
if (RemainingGameplaySaveFailures > 0)
|
|
{
|
|
RemainingGameplaySaveFailures--;
|
|
throw new IOException("gameplay persistence failed");
|
|
}
|
|
if (ThrowOnGameplaySave)
|
|
throw new IOException("gameplay persistence failed");
|
|
GameplayValue = gameplay;
|
|
}
|
|
|
|
public void SaveChat(ChatSettings chat)
|
|
{
|
|
_events.Add("save-chat");
|
|
if (ThrowOnChatSave)
|
|
throw new IOException("chat persistence failed");
|
|
ChatValue = chat;
|
|
}
|
|
|
|
public void SaveCharacter(string toonKey, CharacterSettings character)
|
|
{
|
|
_events.Add($"save-character:{toonKey}");
|
|
if (ThrowOnCharacterSave)
|
|
throw new IOException("character persistence failed");
|
|
Characters[toonKey] = character;
|
|
if (string.Equals(toonKey, "default", StringComparison.OrdinalIgnoreCase))
|
|
DefaultCharacterValue = character;
|
|
}
|
|
|
|
public void ClearEvents() => _events.Clear();
|
|
}
|
|
|
|
private sealed class FakeClock : IFramePacingClock
|
|
{
|
|
public long Frequency => 1_000;
|
|
|
|
public long GetTimestamp() => 0;
|
|
}
|
|
|
|
private sealed class NullWaiter : IFramePacingWaiter
|
|
{
|
|
public void Wait(long durationTicks, long clockFrequency)
|
|
{
|
|
}
|
|
}
|
|
|
|
private sealed class FakePacingSurface : IDisplayFramePacingSurface
|
|
{
|
|
private int? _refreshRate;
|
|
|
|
public bool VSync { get; set; }
|
|
|
|
public int RefreshReadCount { get; private set; }
|
|
|
|
public int? ActiveMonitorRefreshHz
|
|
{
|
|
get
|
|
{
|
|
RefreshReadCount++;
|
|
return _refreshRate;
|
|
}
|
|
set => _refreshRate = value;
|
|
}
|
|
}
|
|
|
|
private sealed class NullKeyboardSource : IKeyboardSource
|
|
{
|
|
#pragma warning disable CS0067
|
|
public event Action<Key, ModifierMask>? KeyDown;
|
|
public event Action<Key, ModifierMask>? KeyUp;
|
|
#pragma warning restore CS0067
|
|
public bool IsHeld(Key key) => false;
|
|
public ModifierMask CurrentModifiers => ModifierMask.None;
|
|
}
|
|
|
|
private sealed class NullMouseSource : IMouseSource
|
|
{
|
|
#pragma warning disable CS0067
|
|
public event Action<MouseButton, ModifierMask>? MouseDown;
|
|
public event Action<MouseButton, ModifierMask>? MouseUp;
|
|
public event Action<float, float>? MouseMove;
|
|
public event Action<float>? Scroll;
|
|
#pragma warning restore CS0067
|
|
public bool IsHeld(MouseButton button) => false;
|
|
public bool WantCaptureMouse => false;
|
|
public bool WantCaptureKeyboard => false;
|
|
}
|
|
}
|