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(), 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() .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 OptionalFailurePoints() { var data = new TheoryData(); foreach (SettingsDevToolsCompositionPoint point in Enum.GetValues()) { 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(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(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(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(null!, null!); Content = (ContentEffectsAudioResult)RuntimeHelpers.GetUninitializedObject( typeof(ContentEffectsAudioResult)); Dependencies = new SettingsDevToolsDependencies( DispatchProxy.Create(), 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 Points { get; } = []; public List 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 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 Gamepads { get; } = []; public IReadOnlyList Joysticks { get; } = []; public IReadOnlyList Keyboards { get; } = []; public IReadOnlyList Mice { get; } = []; public IReadOnlyList OtherDevices { get; } = []; #pragma warning disable CS0067 public event Action? 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? KeyDown; public event Action? 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? MouseDown; public event Action? MouseUp; public event Action? MouseMove; public event Action? 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(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."); } }