using System.Numerics; using System.Reflection; using AcDream.App.Diagnostics; using AcDream.App.Rendering; using AcDream.UI.Abstractions; namespace AcDream.App.Tests.Rendering; public sealed class DevToolsFramePresenterTests { [Fact] public void Frame_PreservesBeginThenMenuPanelsAndDrawDataOrder() { var calls = new List(); var backend = new RecordingBackend(calls); var commands = new RecordingCommandSource(); var presenter = Create(backend, commands: commands); presenter.BeginFrame(0.25f); presenter.Render(0.5, 1280, 720); Assert.Equal( [ "begin:0.25", "begin-bar", "begin-menu:View", "begin-menu:Camera", "end-bar", "panels:0.5", "draw-data", ], calls); Assert.Same(commands.Current, backend.Contexts.Single().Commands); } [Fact] public void Render_ResolvesTheCurrentCommandBusEveryFrame() { var backend = new RecordingBackend([]); var first = new RecordingCommandBus(); var second = new RecordingCommandBus(); var commands = new RecordingCommandSource { Current = first }; var presenter = Create(backend, commands: commands); presenter.BeginFrame(0.1f); presenter.Render(0.1, 800, 600); commands.Current = second; presenter.BeginFrame(0.2f); presenter.Render(0.2, 800, 600); Assert.Same(first, backend.Contexts[0].Commands); Assert.Same(second, backend.Contexts[1].Commands); } [Fact] public void ViewMenu_TogglesExactPanelsAndUsesCurrentViewportForReset() { var backend = new RecordingBackend([]); backend.OpenMenus.Add("View"); backend.ClickedItems.UnionWith( ["Settings", "Vitals", "Chat", "Debug", "Reset window layout"]); var panels = new RecordingPanels(); var presenter = Create(backend, panels: panels); presenter.BeginFrame(0.1f); presenter.Render(0.1, 1920, 1080); Assert.Equal( [ DevToolsPanelKind.Settings, DevToolsPanelKind.Vitals, DevToolsPanelKind.Chat, DevToolsPanelKind.Debug, ], panels.Toggles); Assert.Equal( [ new LayoutCall("Vitals", new Vector2(10f, 30f), new Vector2(220f, 110f)), new LayoutCall("Chat", new Vector2(10f, 760f), new Vector2(450f, 300f)), new LayoutCall("Debug", new Vector2(1540f, 30f), new Vector2(370f, 520f)), new LayoutCall("Settings", new Vector2(610f, 290f), new Vector2(700f, 500f)), ], backend.Layouts.Select(call => call.WithoutCondition())); Assert.All( backend.Layouts, call => Assert.Equal(DevToolsPanelLayoutCondition.Always, call.Condition)); } [Fact] public void CameraMenu_UsesFlyLabelAndRoundTripsCollisionState() { var backend = new RecordingBackend([]); backend.OpenMenus.Add("Camera"); backend.ClickedItems.UnionWith( ["Exit Free-Fly Mode", "Collide Camera (spring arm)"]); var camera = new RecordingCamera { IsFlyMode = true, CollideCamera = true }; var presenter = Create(backend, camera: camera); presenter.BeginFrame(0.1f); presenter.Render(0.1, 1280, 720); Assert.Equal(1, camera.ToggleCount); Assert.False(camera.CollideCamera); Assert.Contains( backend.MenuItems, item => item.Label == "Collide Camera (spring arm)" && item.Selected); } [Fact] public void MissingSettingsPanel_IsInertAndSkippedFromLayout() { var backend = new RecordingBackend([]); backend.OpenMenus.Add("View"); backend.ClickedItems.Add("Settings"); var panels = new RecordingPanels { HasSettings = false }; var presenter = Create(backend, panels: panels); presenter.ToggleSettingsPanel(); presenter.ResetLayout(1280, 720, DevToolsPanelLayoutCondition.FirstUseEver); presenter.BeginFrame(0.1f); presenter.Render(0.1, 1280, 720); Assert.DoesNotContain(DevToolsPanelKind.Settings, panels.Toggles); Assert.DoesNotContain(backend.MenuItems, item => item.Label == "Settings"); Assert.DoesNotContain(backend.Layouts, call => call.Title == "Settings"); } [Theory] [InlineData(1280, 720, 900, 400, 290, 110)] [InlineData(1920, 1080, 1540, 760, 610, 290)] [InlineData(100, 100, 100, 0, -110, -90)] public void ResetLayout_PreservesExactFormulasAndMinimumViewport( int width, int height, int debugX, int chatY, int settingsX, int settingsY) { var backend = new RecordingBackend([]); var presenter = Create(backend); presenter.ResetLayout(width, height, DevToolsPanelLayoutCondition.FirstUseEver); Assert.Collection( backend.Layouts, call => Assert.Equal( new LayoutCall("Vitals", new Vector2(10f, 30f), new Vector2(220f, 110f)), call.WithoutCondition()), call => Assert.Equal( new LayoutCall("Chat", new Vector2(10f, chatY), new Vector2(450f, 300f)), call.WithoutCondition()), call => Assert.Equal( new LayoutCall("Debug", new Vector2(debugX, 30f), new Vector2(370f, 520f)), call.WithoutCondition()), call => Assert.Equal( new LayoutCall( "Settings", new Vector2(settingsX, settingsY), new Vector2(700f, 500f)), call.WithoutCondition())); } [Fact] public void AbortFrame_ClosesAnOpenBackendFrameAndAllowsTheNextFrame() { var calls = new List(); var backend = new RecordingBackend(calls); var presenter = Create(backend); presenter.BeginFrame(0.1f); presenter.AbortFrame(); presenter.AbortFrame(); presenter.BeginFrame(0.2f); presenter.Render(0.2, 800, 600); Assert.Equal(1, calls.Count(call => call == "abort")); Assert.Equal(2, calls.Count(call => call.StartsWith("begin:"))); Assert.Equal(1, calls.Count(call => call == "draw-data")); } [Fact] public void RenderFailure_RemainsAbortableAndDoesNotPoisonTheNextFrame() { var calls = new List(); var backend = new RecordingBackend(calls) { DrawFailure = new InvalidOperationException("draw"), }; var presenter = Create(backend); presenter.BeginFrame(0.1f); Assert.Throws(() => presenter.Render(0.1, 800, 600)); presenter.AbortFrame(); backend.DrawFailure = null; presenter.BeginFrame(0.2f); presenter.Render(0.2, 800, 600); Assert.Equal(0, calls.Count(call => call == "abort")); Assert.Equal(2, calls.Count(call => call == "draw-data")); } [Fact] public void BeginFailure_RemainsAbortableAndDoesNotPoisonTheNextFrame() { var calls = new List(); var backend = new RecordingBackend(calls) { BeginFailure = new InvalidOperationException("begin"), }; var presenter = Create(backend); Assert.Throws(() => presenter.BeginFrame(0.1f)); presenter.AbortFrame(); backend.BeginFailure = null; presenter.BeginFrame(0.2f); presenter.Render(0.2, 800, 600); Assert.Equal(1, calls.Count(call => call == "abort")); Assert.Equal(2, calls.Count(call => call.StartsWith("begin:"))); } [Fact] public void InputFacingActionsDelegateToTheTypedPanelOwner() { var panels = new RecordingPanels(); var presenter = Create(new RecordingBackend([]), panels: panels); presenter.ToggleDebugPanel(); presenter.FocusChatInput(); presenter.ToggleSettingsPanel(); Assert.Equal( [DevToolsPanelKind.Debug, DevToolsPanelKind.Settings], panels.Toggles); Assert.Equal(1, panels.FocusChatCount); Assert.Equal(["Debug panel ON"], panels.Toasts); } [Fact] public void PresenterAndAdaptersHaveNoWindowOrDelegateBackReferences() { Type[] owners = [ typeof(DevToolsFramePresenter), typeof(ImGuiDevToolsFrameBackend), typeof(DevToolsCameraMenuOperations), typeof(DevToolsCommandBusSource), typeof(DevToolsPanelSet), ]; foreach (Type owner in owners) { FieldInfo[] fields = owner.GetFields( BindingFlags.Instance | BindingFlags.NonPublic); Assert.DoesNotContain(fields, field => field.FieldType == typeof(GameWindow)); Assert.DoesNotContain( fields, field => typeof(Delegate).IsAssignableFrom(field.FieldType)); Assert.DoesNotContain( fields, field => field.FieldType == typeof(Silk.NET.Windowing.IWindow)); } Assert.True(typeof(IDisposable).IsAssignableFrom( typeof(ImGuiDevToolsFrameBackend))); } private static DevToolsFramePresenter Create( RecordingBackend backend, RecordingCamera? camera = null, RecordingCommandSource? commands = null, RecordingPanels? panels = null) => new( backend, camera ?? new RecordingCamera(), commands ?? new RecordingCommandSource(), new FrameProfiler(), panels ?? new RecordingPanels()); private sealed class RecordingBackend(List calls) : IDevToolsFrameBackend { public HashSet OpenMenus { get; } = []; public HashSet ClickedItems { get; } = []; public List<(string Label, string? Shortcut, bool Selected)> MenuItems { get; } = []; public List Contexts { get; } = []; public List Layouts { get; } = []; public Exception? BeginFailure { get; set; } public Exception? DrawFailure { get; set; } public bool ControllerFrameOpen { get; private set; } public void BeginFrame(float deltaSeconds) { calls.Add($"begin:{deltaSeconds}"); ControllerFrameOpen = true; if (BeginFailure is not null) throw BeginFailure; } public void AbortFrame() { // Silk ImGuiController.Render is idempotent. It closes an active // controller frame, but is a no-op if Render already cleared its // private _frameBegun flag before a GL upload failure. if (!ControllerFrameOpen) return; ControllerFrameOpen = false; calls.Add("abort"); } public bool BeginMainMenuBar() { calls.Add("begin-bar"); return true; } public void EndMainMenuBar() => calls.Add("end-bar"); public bool BeginMenu(string label) { calls.Add($"begin-menu:{label}"); return OpenMenus.Contains(label); } public void EndMenu() => calls.Add("end-menu"); public bool MenuItem(string label, string? shortcut = null, bool selected = false) { MenuItems.Add((label, shortcut, selected)); return ClickedItems.Contains(label); } public void Separator() => calls.Add("separator"); public void RenderPanels(PanelContext context) { Contexts.Add(context); calls.Add($"panels:{context.DeltaSeconds}"); } public void RenderDrawData() { Assert.True(ControllerFrameOpen); ControllerFrameOpen = false; calls.Add("draw-data"); if (DrawFailure is not null) throw DrawFailure; } public void SetWindowLayout( string title, Vector2 position, Vector2 size, DevToolsPanelLayoutCondition condition) => Layouts.Add(new LayoutCallWithCondition(title, position, size, condition)); } private sealed class RecordingCamera : IDevToolsCameraMenuOperations { public bool IsFlyMode { get; init; } public bool CollideCamera { get; set; } public int ToggleCount { get; private set; } public void ToggleFlyOrChase() => ToggleCount++; } private sealed class RecordingCommandSource : IDevToolsCommandBusSource { public ICommandBus Current { get; set; } = new RecordingCommandBus(); } private sealed class RecordingCommandBus : ICommandBus { public void Publish(T command) where T : notnull { } } private sealed class RecordingPanels : IDevToolsPanelSet { private readonly HashSet _visible = []; public bool HasSettings { get; init; } = true; public List Toggles { get; } = []; public List Toasts { get; } = []; public int FocusChatCount { get; private set; } public bool Contains(DevToolsPanelKind kind) => kind != DevToolsPanelKind.Settings || HasSettings; public string? GetTitle(DevToolsPanelKind kind) => Contains(kind) ? kind.ToString() : null; public bool IsVisible(DevToolsPanelKind kind) => _visible.Contains(kind); public void Toggle(DevToolsPanelKind kind) { if (!Contains(kind)) return; Toggles.Add(kind); if (!_visible.Add(kind)) _visible.Remove(kind); } public void FocusChatInput() => FocusChatCount++; public void AddDebugToast(string message) => Toasts.Add(message); } private readonly record struct LayoutCall( string Title, Vector2 Position, Vector2 Size); private readonly record struct LayoutCallWithCondition( string Title, Vector2 Position, Vector2 Size, DevToolsPanelLayoutCondition Condition) { public LayoutCall WithoutCondition() => new(Title, Position, Size); } }