refactor(app): compose settings and developer tools
This commit is contained in:
parent
60a1698ce7
commit
cd7b519f78
24 changed files with 2073 additions and 333 deletions
|
|
@ -0,0 +1,520 @@
|
|||
using System.Numerics;
|
||||
using System.Reflection;
|
||||
using System.Runtime.CompilerServices;
|
||||
using AcDream.App.Combat;
|
||||
using AcDream.App.Composition;
|
||||
using AcDream.App.Diagnostics;
|
||||
using AcDream.App.Input;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.App.Settings;
|
||||
using AcDream.Core.Chat;
|
||||
using AcDream.Core.Combat;
|
||||
using AcDream.Core.Player;
|
||||
using AcDream.Core.Spells;
|
||||
using AcDream.UI.Abstractions.Input;
|
||||
using AcDream.UI.Abstractions.Panels.Settings;
|
||||
using AcDream.UI.Abstractions.Settings;
|
||||
using AcDream.UI.ImGui;
|
||||
using Silk.NET.Input;
|
||||
using Silk.NET.Maths;
|
||||
using Silk.NET.OpenGL;
|
||||
using Silk.NET.Windowing;
|
||||
|
||||
namespace AcDream.App.Tests.Composition;
|
||||
|
||||
public sealed class SettingsDevToolsCompositionTests
|
||||
{
|
||||
[Fact]
|
||||
public void DisabledFrontendAppliesSettingsAndAcquiresNothingOptional()
|
||||
{
|
||||
using var fixture = new Fixture(enabled: false);
|
||||
|
||||
SettingsDevToolsResult result = fixture.Compose();
|
||||
|
||||
Assert.Null(result.DevTools);
|
||||
Assert.Equal(1, fixture.Startup.DisplayCalls);
|
||||
Assert.Equal(1, fixture.Startup.AudioCalls);
|
||||
Assert.Equal(0, fixture.Factory.Calls);
|
||||
Assert.Equal(
|
||||
[SettingsDevToolsCompositionPoint.SettingsApplied],
|
||||
fixture.Points);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnsupportedInputDisablesBeforeAcquiringFrontendResources()
|
||||
{
|
||||
using var fixture = new Fixture(inputSupported: false);
|
||||
|
||||
SettingsDevToolsResult result = fixture.Compose();
|
||||
|
||||
Assert.Null(result.DevTools);
|
||||
Assert.Null(fixture.Factory.Input);
|
||||
Assert.Null(fixture.Factory.Bootstrap);
|
||||
Assert.Equal(1, fixture.Factory.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnabledFrontendPublishesOneCompleteOwnerAfterEveryEdge()
|
||||
{
|
||||
using var fixture = new Fixture();
|
||||
|
||||
SettingsDevToolsResult result = fixture.Compose();
|
||||
|
||||
Assert.Same(fixture.Publication.Owner, result.DevTools);
|
||||
Assert.Equal(
|
||||
Enum.GetValues<SettingsDevToolsCompositionPoint>(),
|
||||
fixture.Points);
|
||||
Assert.True(fixture.Factory.Input!.Activated);
|
||||
Assert.Equal(4, fixture.Factory.Backend!.LayoutCalls);
|
||||
Assert.Equal(1, fixture.Publication.PublishCalls);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(OptionalFailurePoints))]
|
||||
public void OptionalPrefixFailureDisablesOnlyAfterCompleteReverseCleanup(
|
||||
int pointValue)
|
||||
{
|
||||
var point = (SettingsDevToolsCompositionPoint)pointValue;
|
||||
using var fixture = new Fixture(failurePoint: point);
|
||||
|
||||
SettingsDevToolsResult result = fixture.Compose();
|
||||
|
||||
Assert.Null(result.DevTools);
|
||||
Assert.Null(fixture.Publication.Owner);
|
||||
Assert.Equal(
|
||||
Enum.GetValues<SettingsDevToolsCompositionPoint>()
|
||||
.TakeWhile(candidate => candidate <= point),
|
||||
fixture.Points);
|
||||
if (fixture.Factory.Input is { } input)
|
||||
Assert.True(input.IsDisposalComplete);
|
||||
if (fixture.Factory.Bootstrap is { } bootstrap)
|
||||
Assert.Equal(1, bootstrap.DisposeCalls);
|
||||
if (fixture.Factory.Backend is { } backend)
|
||||
Assert.Equal(1, backend.DisposeCalls);
|
||||
}
|
||||
|
||||
public static TheoryData<int> OptionalFailurePoints()
|
||||
{
|
||||
var data = new TheoryData<int>();
|
||||
foreach (SettingsDevToolsCompositionPoint point in
|
||||
Enum.GetValues<SettingsDevToolsCompositionPoint>())
|
||||
{
|
||||
if (point is SettingsDevToolsCompositionPoint.SettingsApplied
|
||||
or SettingsDevToolsCompositionPoint.DevToolsPublished)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
data.Add((int)point);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FailureAfterPublicationPropagatesToLifetimeOwner()
|
||||
{
|
||||
using var fixture = new Fixture(
|
||||
failurePoint: SettingsDevToolsCompositionPoint.DevToolsPublished);
|
||||
|
||||
Assert.Throws<InvalidOperationException>(fixture.Compose);
|
||||
|
||||
Assert.NotNull(fixture.Publication.Owner);
|
||||
Assert.False(fixture.Publication.Owner!.IsDisposalComplete);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CleanupFailurePropagatesAndRetrySkipsCompletedOperations()
|
||||
{
|
||||
using var fixture = new Fixture(
|
||||
failurePoint: SettingsDevToolsCompositionPoint.InitialLayoutApplied,
|
||||
backendDisposeFailures: 1);
|
||||
|
||||
var failure = Assert.Throws<CompositionAcquisitionException>(fixture.Compose);
|
||||
Assert.False(failure.IsCleanupComplete);
|
||||
Assert.Equal(1, fixture.Factory.Backend!.DisposeCalls);
|
||||
Assert.True(fixture.Factory.Input!.IsDisposalComplete);
|
||||
|
||||
failure.RetryCleanup();
|
||||
|
||||
Assert.True(failure.IsCleanupComplete);
|
||||
Assert.Equal(2, fixture.Factory.Backend.DisposeCalls);
|
||||
Assert.Equal(1, fixture.Factory.Input.DisposeCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BootstrapConstructionWithoutCleanupOwnerAbortsHostStartup()
|
||||
{
|
||||
var bootstrapFailure = (ImGuiBootstrapperConstructionException)
|
||||
Activator.CreateInstance(
|
||||
typeof(ImGuiBootstrapperConstructionException),
|
||||
BindingFlags.Instance | BindingFlags.NonPublic,
|
||||
binder: null,
|
||||
args: [new InvalidOperationException("partial Silk construction")],
|
||||
culture: null)!;
|
||||
using var fixture = new Fixture(bootstrapFailure: bootstrapFailure);
|
||||
|
||||
Assert.Throws<ImGuiBootstrapperConstructionException>(fixture.Compose);
|
||||
|
||||
Assert.True(fixture.Factory.Input!.IsDisposalComplete);
|
||||
Assert.Null(fixture.Publication.Owner);
|
||||
Assert.DoesNotContain(
|
||||
fixture.Logs,
|
||||
message => message.Contains("devtools disabled", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GameWindowUsesProductionPhaseAndDoesNotRetainHostClosures()
|
||||
{
|
||||
string source = File.ReadAllText(Path.Combine(
|
||||
FindRepoRoot(),
|
||||
"src",
|
||||
"AcDream.App",
|
||||
"Rendering",
|
||||
"GameWindow.cs"));
|
||||
|
||||
Assert.Contains("new SettingsDevToolsCompositionPhase(", source,
|
||||
StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("new AcDream.UI.ImGui.ImGuiBootstrapper(", source,
|
||||
StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("getPlayerPosition: () => GetDebugPlayerPosition()", source,
|
||||
StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("_runtimeSettings.ApplyStartup(", source,
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private sealed class Fixture : IDisposable
|
||||
{
|
||||
private readonly SettingsDevToolsCompositionPoint? _failurePoint;
|
||||
private readonly InputDispatcher _dispatcher;
|
||||
private readonly FrameProfiler _profiler = new();
|
||||
|
||||
public Fixture(
|
||||
bool enabled = true,
|
||||
SettingsDevToolsCompositionPoint? failurePoint = null,
|
||||
int backendDisposeFailures = 0,
|
||||
bool inputSupported = true,
|
||||
Exception? bootstrapFailure = null)
|
||||
{
|
||||
_failurePoint = failurePoint;
|
||||
Factory = new Factory(
|
||||
backendDisposeFailures,
|
||||
inputSupported,
|
||||
bootstrapFailure);
|
||||
Publication = new Publication();
|
||||
Startup = new StartupTarget();
|
||||
Settings = new RuntimeSettingsController(
|
||||
new Storage(),
|
||||
QualitySettings.From,
|
||||
static _ => { });
|
||||
_dispatcher = InputDispatcher.CreateDetached(
|
||||
new KeyboardSource(),
|
||||
new MouseSource(),
|
||||
new KeyBindings());
|
||||
_dispatcher.Attach();
|
||||
var camera = new CameraController(new OrbitCamera(), new FlyCamera());
|
||||
Host = new HostInputCameraResult(
|
||||
null!,
|
||||
null!,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
_dispatcher,
|
||||
camera,
|
||||
null);
|
||||
Platform = new GameWindowPlatformResult<GL, IInputContext>(null!, null!);
|
||||
Content = (ContentEffectsAudioResult)RuntimeHelpers.GetUninitializedObject(
|
||||
typeof(ContentEffectsAudioResult));
|
||||
Dependencies = new SettingsDevToolsDependencies(
|
||||
DispatchProxy.Create<IView, ViewProxy>(),
|
||||
Settings,
|
||||
Startup,
|
||||
new HostQuiescenceGate(),
|
||||
new ChatLog(),
|
||||
new CombatState(),
|
||||
new LocalPlayerState(new Spellbook()),
|
||||
enabled
|
||||
? new SettingsDevToolsOptionalDependencies(
|
||||
new Facts(),
|
||||
new KeyBindingTarget(),
|
||||
new DeferredCanonicalWorldEntityCountSource(),
|
||||
new DeferredRenderFrameDiagnosticsSource(),
|
||||
new DeferredDevToolsPlayerModeCommands())
|
||||
: null,
|
||||
new RuntimeDiagnosticCommandSlot(),
|
||||
new CombatFeedbackSlot(),
|
||||
new KeyBindings(),
|
||||
_profiler,
|
||||
new FramebufferResizeController(new ViewportAspectState()),
|
||||
Logs.Add);
|
||||
}
|
||||
|
||||
public List<SettingsDevToolsCompositionPoint> Points { get; } = [];
|
||||
public List<string> Logs { get; } = [];
|
||||
public Factory Factory { get; }
|
||||
public Publication Publication { get; }
|
||||
public StartupTarget Startup { get; }
|
||||
public RuntimeSettingsController Settings { get; }
|
||||
public SettingsDevToolsDependencies Dependencies { get; }
|
||||
public HostInputCameraResult Host { get; }
|
||||
public GameWindowPlatformResult<GL, IInputContext> Platform { get; }
|
||||
public ContentEffectsAudioResult Content { get; }
|
||||
|
||||
public SettingsDevToolsResult Compose() =>
|
||||
new SettingsDevToolsCompositionPhase(
|
||||
Dependencies,
|
||||
Publication,
|
||||
Factory,
|
||||
point =>
|
||||
{
|
||||
Points.Add(point);
|
||||
if (point == _failurePoint)
|
||||
throw new InvalidOperationException($"fault at {point}");
|
||||
}).Compose(Platform, Host, Content);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Publication.Owner?.Dispose();
|
||||
_dispatcher.Dispose();
|
||||
_profiler.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class Publication : IGameWindowSettingsDevToolsPublication
|
||||
{
|
||||
public DevToolsCompositionOwner? Owner { get; private set; }
|
||||
public int PublishCalls { get; private set; }
|
||||
|
||||
public void PublishDevTools(DevToolsCompositionOwner value)
|
||||
{
|
||||
if (Owner is not null)
|
||||
throw new InvalidOperationException("duplicate publication");
|
||||
Owner = value;
|
||||
PublishCalls++;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class Factory(
|
||||
int backendDisposeFailures,
|
||||
bool inputSupported,
|
||||
Exception? bootstrapFailure)
|
||||
: ISettingsDevToolsCompositionFactory
|
||||
{
|
||||
public int Calls { get; private set; }
|
||||
public InputContext? Input { get; private set; }
|
||||
public Bootstrap? Bootstrap { get; private set; }
|
||||
public Backend? Backend { get; private set; }
|
||||
|
||||
public bool IsSupported(IInputContext input)
|
||||
{
|
||||
Calls++;
|
||||
return inputSupported;
|
||||
}
|
||||
|
||||
public IDevToolsInputContext CreateInputContext(
|
||||
IInputContext input,
|
||||
HostQuiescenceGate quiescence)
|
||||
{
|
||||
Calls++;
|
||||
return Input = new InputContext();
|
||||
}
|
||||
|
||||
public IImGuiBootstrapper CreateBootstrap(
|
||||
GL gl,
|
||||
IView window,
|
||||
IInputContext input)
|
||||
{
|
||||
Calls++;
|
||||
if (bootstrapFailure is not null)
|
||||
throw bootstrapFailure;
|
||||
return Bootstrap = new Bootstrap();
|
||||
}
|
||||
|
||||
public ImGuiPanelHost CreatePanelHost()
|
||||
{
|
||||
Calls++;
|
||||
return new ImGuiPanelHost();
|
||||
}
|
||||
|
||||
public IDevToolsFrameBackend CreateBackend(
|
||||
IImGuiBootstrapper bootstrap,
|
||||
ImGuiPanelHost panels)
|
||||
{
|
||||
Calls++;
|
||||
return Backend = new Backend(
|
||||
(Bootstrap)bootstrap,
|
||||
backendDisposeFailures);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class InputContext : IDevToolsInputContext
|
||||
{
|
||||
public bool Activated { get; private set; }
|
||||
public int DisposeCalls { get; private set; }
|
||||
public bool IsDisposalComplete { get; private set; }
|
||||
public nint Handle => 0;
|
||||
public IReadOnlyList<IGamepad> Gamepads { get; } = [];
|
||||
public IReadOnlyList<IJoystick> Joysticks { get; } = [];
|
||||
public IReadOnlyList<IKeyboard> Keyboards { get; } = [];
|
||||
public IReadOnlyList<IMouse> Mice { get; } = [];
|
||||
public IReadOnlyList<IInputDevice> OtherDevices { get; } = [];
|
||||
#pragma warning disable CS0067
|
||||
public event Action<IInputDevice, bool>? ConnectionChanged;
|
||||
#pragma warning restore CS0067
|
||||
public void Activate() => Activated = true;
|
||||
public void Deactivate() => Activated = false;
|
||||
public void Dispose()
|
||||
{
|
||||
DisposeCalls++;
|
||||
Activated = false;
|
||||
IsDisposalComplete = true;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class Bootstrap : IImGuiBootstrapper
|
||||
{
|
||||
public int DisposeCalls { get; private set; }
|
||||
public void BeginFrame(float deltaSeconds) { }
|
||||
public void Render() { }
|
||||
public void AbortFrame() { }
|
||||
public void Dispose() => DisposeCalls++;
|
||||
}
|
||||
|
||||
private sealed class Backend(
|
||||
Bootstrap bootstrap,
|
||||
int remainingDisposeFailures) : IDevToolsFrameBackend
|
||||
{
|
||||
private int _remainingDisposeFailures = remainingDisposeFailures;
|
||||
public int DisposeCalls { get; private set; }
|
||||
public int LayoutCalls { get; private set; }
|
||||
public void BeginFrame(float deltaSeconds) { }
|
||||
public void AbortFrame() { }
|
||||
public bool BeginMainMenuBar() => false;
|
||||
public void EndMainMenuBar() { }
|
||||
public bool BeginMenu(string label) => false;
|
||||
public void EndMenu() { }
|
||||
public bool MenuItem(string label, string? shortcut = null, bool selected = false) => false;
|
||||
public void Separator() { }
|
||||
public void RenderPanels(AcDream.UI.Abstractions.PanelContext context) { }
|
||||
public void RenderDrawData() { }
|
||||
public void SetWindowLayout(
|
||||
string title,
|
||||
Vector2 position,
|
||||
Vector2 size,
|
||||
DevToolsPanelLayoutCondition condition) => LayoutCalls++;
|
||||
public void Dispose()
|
||||
{
|
||||
DisposeCalls++;
|
||||
if (_remainingDisposeFailures-- > 0)
|
||||
throw new InvalidOperationException("backend cleanup failed");
|
||||
bootstrap.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class StartupTarget : IRuntimeSettingsStartupTarget
|
||||
{
|
||||
public int DisplayCalls { get; private set; }
|
||||
public int AudioCalls { get; private set; }
|
||||
public void ApplyDisplay(DisplaySettings display) => DisplayCalls++;
|
||||
public void ApplyAudio(AudioSettings audio) => AudioCalls++;
|
||||
}
|
||||
|
||||
private sealed class Storage : IRuntimeSettingsStorage
|
||||
{
|
||||
public SettingsStore? LayoutStore => null;
|
||||
public string Location => "memory://settings";
|
||||
public DisplaySettings LoadDisplay() => DisplaySettings.Default;
|
||||
public AudioSettings LoadAudio() => AudioSettings.Default;
|
||||
public GameplaySettings LoadGameplay() => GameplaySettings.Default;
|
||||
public ChatSettings LoadChat() => ChatSettings.Default;
|
||||
public CharacterSettings LoadCharacter(string toonKey) => CharacterSettings.Default;
|
||||
public void SaveDisplay(DisplaySettings display) { }
|
||||
public void SaveAudio(AudioSettings audio) { }
|
||||
public void SaveGameplay(GameplaySettings gameplay) { }
|
||||
public void SaveChat(ChatSettings chat) { }
|
||||
public void SaveCharacter(string toonKey, CharacterSettings character) { }
|
||||
}
|
||||
|
||||
private sealed class KeyBindingTarget : IRuntimeKeyBindingTarget
|
||||
{
|
||||
public void Apply(KeyBindings bindings) { }
|
||||
}
|
||||
|
||||
private sealed class Facts : IDevToolsRuntimeFacts
|
||||
{
|
||||
public Vector3 PlayerPosition => default;
|
||||
public float PlayerHeadingDegrees => 0;
|
||||
public uint PlayerCellId => 0;
|
||||
public bool PlayerOnGround => false;
|
||||
public bool InPlayerMode => false;
|
||||
public bool InFlyMode => false;
|
||||
public float VerticalVelocity => 0;
|
||||
public int EntityCount => 0;
|
||||
public int AnimatedCount => 0;
|
||||
public int VisibleLandblocks => 0;
|
||||
public int TotalLandblocks => 0;
|
||||
public int ShadowObjectCount => 0;
|
||||
public float NearestObjectDistance => float.PositiveInfinity;
|
||||
public string NearestObjectLabel => "-";
|
||||
public bool Colliding => false;
|
||||
public bool CollisionWireframesVisible => false;
|
||||
public int StreamingRadius => 0;
|
||||
public float MouseSensitivity => 1;
|
||||
public float ChaseDistance => 0;
|
||||
public bool RmbOrbitHeld => false;
|
||||
public string HourName => "0";
|
||||
public float DayFraction => 0;
|
||||
public string Weather => "Clear";
|
||||
public int ActiveLights => 0;
|
||||
public int RegisteredLights => 0;
|
||||
public int ParticleCount => 0;
|
||||
public float Fps => 60;
|
||||
public float FrameMilliseconds => 16.7f;
|
||||
}
|
||||
|
||||
private sealed class KeyboardSource : 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 MouseSource : 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;
|
||||
}
|
||||
|
||||
public class ViewProxy : DispatchProxy
|
||||
{
|
||||
protected override object? Invoke(MethodInfo? targetMethod, object?[]? args)
|
||||
{
|
||||
if (targetMethod?.Name == "get_Size")
|
||||
return new Vector2D<int>(1280, 720);
|
||||
Type type = targetMethod?.ReturnType ?? typeof(void);
|
||||
if (type == typeof(void))
|
||||
return null;
|
||||
return type.IsValueType ? Activator.CreateInstance(type) : null;
|
||||
}
|
||||
}
|
||||
|
||||
private static string FindRepoRoot()
|
||||
{
|
||||
DirectoryInfo? directory = new(AppContext.BaseDirectory);
|
||||
while (directory is not null)
|
||||
{
|
||||
if (File.Exists(Path.Combine(directory.FullName, "AcDream.slnx")))
|
||||
return directory.FullName;
|
||||
directory = directory.Parent;
|
||||
}
|
||||
throw new DirectoryNotFoundException("Could not find AcDream.slnx.");
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue