Move pre-window loading, startup application, live settings mutation, toon context, quality reapply, and SettingsVM loans behind one RuntimeSettingsController. Preserve retail command behavior, ordered target publication, draft semantics, and retryable failure convergence while removing duplicate GameWindow state and feature bodies. Co-authored-by: Codex <codex@openai.com>
1159 lines
40 KiB
C#
1159 lines
40 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 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;
|
|
}
|
|
}
|