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
59
tests/AcDream.App.Tests/Combat/CombatFeedbackSlotTests.cs
Normal file
59
tests/AcDream.App.Tests/Combat/CombatFeedbackSlotTests.cs
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
using System.Numerics;
|
||||
using AcDream.App.Combat;
|
||||
using AcDream.Core.Combat;
|
||||
using AcDream.UI.Abstractions.Panels.Debug;
|
||||
|
||||
namespace AcDream.App.Tests.Combat;
|
||||
|
||||
public sealed class CombatFeedbackSlotTests
|
||||
{
|
||||
[Fact]
|
||||
public void ExpectedOwnerUnbindCannotClearReplacement()
|
||||
{
|
||||
var slot = new CombatFeedbackSlot();
|
||||
using DebugVM first = CreateViewModel();
|
||||
using DebugVM second = CreateViewModel();
|
||||
|
||||
slot.Bind(first);
|
||||
slot.Unbind(second);
|
||||
slot.Show("first");
|
||||
Assert.Single(first.RecentToasts);
|
||||
Assert.Empty(second.RecentToasts);
|
||||
|
||||
slot.Unbind(first);
|
||||
slot.Bind(second);
|
||||
slot.Show("second");
|
||||
Assert.Single(second.RecentToasts);
|
||||
}
|
||||
|
||||
private static DebugVM CreateViewModel() => new(
|
||||
static () => Vector3.Zero,
|
||||
static () => 0,
|
||||
static () => 0,
|
||||
static () => false,
|
||||
static () => false,
|
||||
static () => false,
|
||||
static () => 0,
|
||||
static () => 0,
|
||||
static () => 0,
|
||||
static () => 0,
|
||||
static () => 0,
|
||||
static () => 0,
|
||||
static () => float.PositiveInfinity,
|
||||
static () => "-",
|
||||
static () => false,
|
||||
static () => false,
|
||||
static () => 0,
|
||||
static () => 1,
|
||||
static () => 0,
|
||||
static () => false,
|
||||
static () => "0",
|
||||
static () => 0,
|
||||
static () => "Clear",
|
||||
static () => 0,
|
||||
static () => 0,
|
||||
static () => 0,
|
||||
static () => 60,
|
||||
static () => 16.7f,
|
||||
new CombatState());
|
||||
}
|
||||
|
|
@ -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.");
|
||||
}
|
||||
}
|
||||
|
|
@ -283,6 +283,7 @@ public sealed class DevToolsFramePresenterTests
|
|||
|
||||
private sealed class RecordingBackend(List<string> calls) : IDevToolsFrameBackend
|
||||
{
|
||||
public void Dispose() { }
|
||||
public HashSet<string> OpenMenus { get; } = [];
|
||||
public HashSet<string> ClickedItems { get; } = [];
|
||||
public List<(string Label, string? Shortcut, bool Selected)> MenuItems { get; } = [];
|
||||
|
|
|
|||
|
|
@ -0,0 +1,98 @@
|
|||
using AcDream.App.Rendering;
|
||||
using AcDream.App.Streaming;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering;
|
||||
|
||||
public sealed class DevToolsRuntimeSourcesTests
|
||||
{
|
||||
[Fact]
|
||||
public void FrameDiagnosticsLateBindingPreservesSeedAndExpectedOwnerRelease()
|
||||
{
|
||||
var source = new DeferredRenderFrameDiagnosticsSource();
|
||||
var first = new FrameSource(144, 6.9);
|
||||
var other = new FrameSource(30, 33.3);
|
||||
|
||||
Assert.Equal(RenderFrameDiagnosticsSnapshot.Initial, source.Snapshot);
|
||||
source.Bind(first);
|
||||
Assert.Equal(144, source.Snapshot.Fps);
|
||||
source.Unbind(other);
|
||||
Assert.Equal(144, source.Snapshot.Fps);
|
||||
source.Unbind(first);
|
||||
Assert.Equal(RenderFrameDiagnosticsSnapshot.Initial, source.Snapshot);
|
||||
|
||||
source.Bind(other);
|
||||
source.Deactivate();
|
||||
Assert.Equal(RenderFrameDiagnosticsSnapshot.Initial, source.Snapshot);
|
||||
Assert.Throws<ObjectDisposedException>(() => source.Bind(first));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FrameDiagnosticsRejectsACompetingCanonicalOwner()
|
||||
{
|
||||
var source = new DeferredRenderFrameDiagnosticsSource();
|
||||
source.Bind(new FrameSource(60, 16.7));
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
source.Bind(new FrameSource(120, 8.3)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PlayerModeCommandSlotUsesLiveTargetAndRejectsStaleRelease()
|
||||
{
|
||||
var source = new DeferredDevToolsPlayerModeCommands();
|
||||
var first = new PlayerModeTarget();
|
||||
var other = new PlayerModeTarget();
|
||||
|
||||
source.ToggleFlyOrChase();
|
||||
source.Bind(first);
|
||||
source.ToggleFlyOrChase();
|
||||
source.Unbind(other);
|
||||
source.ToggleFlyOrChase();
|
||||
source.Unbind(first);
|
||||
source.ToggleFlyOrChase();
|
||||
|
||||
Assert.Equal(2, first.ToggleCalls);
|
||||
Assert.Equal(0, other.ToggleCalls);
|
||||
|
||||
source.Bind(other);
|
||||
source.Deactivate();
|
||||
source.ToggleFlyOrChase();
|
||||
Assert.Equal(0, other.ToggleCalls);
|
||||
Assert.Throws<ObjectDisposedException>(() => source.Bind(first));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WorldCountSlotCannotFreezeOrReleaseTheWrongWorld()
|
||||
{
|
||||
var source = new DeferredCanonicalWorldEntityCountSource();
|
||||
var first = new GpuWorldState();
|
||||
var other = new GpuWorldState();
|
||||
|
||||
source.Bind(first);
|
||||
source.Unbind(other);
|
||||
Assert.Throws<InvalidOperationException>(() => source.Bind(other));
|
||||
source.Unbind(first);
|
||||
source.Bind(other);
|
||||
source.Deactivate();
|
||||
|
||||
Assert.Equal(0, source.EntityCount);
|
||||
Assert.Throws<ObjectDisposedException>(() => source.Bind(first));
|
||||
}
|
||||
|
||||
private sealed class FrameSource(double fps, double milliseconds)
|
||||
: IRenderFrameDiagnosticsSnapshotSource
|
||||
{
|
||||
public RenderFrameDiagnosticsSnapshot Snapshot { get; } =
|
||||
RenderFrameDiagnosticsSnapshot.Initial with
|
||||
{
|
||||
Fps = fps,
|
||||
FrameMilliseconds = milliseconds,
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class PlayerModeTarget : IDevToolsPlayerModeTarget
|
||||
{
|
||||
public int ToggleCalls { get; private set; }
|
||||
public void ToggleFlyOrChase() => ToggleCalls++;
|
||||
}
|
||||
}
|
||||
|
|
@ -57,6 +57,22 @@ public sealed class FramebufferResizeControllerTests
|
|||
Assert.Equal(["viewport:800x600", "camera:1.333", "devtools:800x600"], calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DevToolsBindingReleasesExpectedTargetWithoutReplayingResize()
|
||||
{
|
||||
var calls = new List<string>();
|
||||
var owner = new FramebufferResizeController(new ViewportAspectState());
|
||||
var target = new DevTools(calls);
|
||||
using var binding = new FramebufferDevToolsBinding(owner, target);
|
||||
|
||||
owner.Resize(900, 700);
|
||||
binding.Dispose();
|
||||
binding.Dispose();
|
||||
owner.Resize(1000, 800);
|
||||
|
||||
Assert.Equal(["devtools:900x700"], calls);
|
||||
}
|
||||
|
||||
private sealed class Viewport(List<string> calls) : IFramebufferViewportTarget
|
||||
{
|
||||
public void ResizeViewport(int width, int height) =>
|
||||
|
|
|
|||
|
|
@ -94,7 +94,13 @@ public sealed class GameWindowRenderLeafCompositionTests
|
|||
Assert.DoesNotContain(identifier, source, StringComparison.Ordinal);
|
||||
|
||||
Assert.Contains("new AcDream.App.Rendering.PaperdollFramePresenter(", source);
|
||||
Assert.Contains("new AcDream.App.Rendering.DevToolsFramePresenter(", source);
|
||||
string settingsComposition = File.ReadAllText(Path.Combine(
|
||||
FindRepoRoot(),
|
||||
"src",
|
||||
"AcDream.App",
|
||||
"Composition",
|
||||
"SettingsDevToolsComposition.cs"));
|
||||
Assert.Contains("new DevToolsFramePresenter(", settingsComposition);
|
||||
Assert.Contains("new AcDream.App.Rendering.RenderFrameResourceController(", source);
|
||||
Assert.Contains("new AcDream.App.Rendering.RenderWeatherFrameController(", source);
|
||||
Assert.Contains("new AcDream.App.Rendering.PrivatePresentationRenderer(", source);
|
||||
|
|
@ -119,7 +125,7 @@ public sealed class GameWindowRenderLeafCompositionTests
|
|||
"new ResourceShutdownStage(\"submitted GPU work\"",
|
||||
"new ResourceShutdownStage(\"render frontends\"",
|
||||
"new(\"developer tools\"",
|
||||
"_devToolsBackend?.Dispose()",
|
||||
"owner.DisposeFrontend()",
|
||||
"new(\"portal tunnel\"",
|
||||
"new(\"paperdoll viewport\"",
|
||||
"new ResourceShutdownStage(\"OpenGL context\"");
|
||||
|
|
@ -135,7 +141,7 @@ public sealed class GameWindowRenderLeafCompositionTests
|
|||
AssertAppearsInOrder(
|
||||
source,
|
||||
"_frameGraphs.Withdraw();",
|
||||
"_devToolsBackend?.Dispose()",
|
||||
"owner.DisposeFrontend()",
|
||||
"new ResourceShutdownStage(\"input\"",
|
||||
"_input?.Dispose();",
|
||||
"new ResourceShutdownStage(\"OpenGL context\"");
|
||||
|
|
|
|||
|
|
@ -56,8 +56,8 @@ public sealed class GameWindowSlice8BoundaryTests
|
|||
"this).Compose(platform);",
|
||||
"new ContentEffectsAudioCompositionPhase(",
|
||||
"this).Compose(platform, hostInputCamera);",
|
||||
"_runtimeSettings.ApplyStartup(",
|
||||
"new RuntimeSettingsStartupTargets(",
|
||||
"new SettingsDevToolsCompositionPhase(",
|
||||
"this).Compose(platform, hostInputCamera, contentEffectsAudio);",
|
||||
"_uiHost = _retailUiLease.AcquireHost(",
|
||||
"_uiHost.WireMouse(m)",
|
||||
"_uiHost.WireKeyboard(kb)",
|
||||
|
|
@ -138,10 +138,19 @@ public sealed class GameWindowSlice8BoundaryTests
|
|||
|
||||
Assert.DoesNotContain("private void CycleTimeOfDay()", source, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("private void CycleWeather()", source, StringComparison.Ordinal);
|
||||
string settingsPhase = File.ReadAllText(Path.Combine(
|
||||
FindRepoRoot(),
|
||||
"src",
|
||||
"AcDream.App",
|
||||
"Composition",
|
||||
"SettingsDevToolsComposition.cs"));
|
||||
AssertAppearsInOrder(
|
||||
settingsPhase,
|
||||
"debugVm.CycleTimeOfDay = _dependencies.DiagnosticCommands.CycleTimeOfDay;",
|
||||
"debugVm.CycleWeather = _dependencies.DiagnosticCommands.CycleWeather;",
|
||||
"debugVm.ToggleCollisionWires =");
|
||||
AssertAppearsInOrder(
|
||||
load,
|
||||
"_debugVm.CycleTimeOfDay =",
|
||||
"_runtimeDiagnosticCommands.CycleTimeOfDay;",
|
||||
"new AcDream.App.Diagnostics.RuntimeDiagnosticCommandController(",
|
||||
"_runtimeDiagnosticCommands.Bind(runtimeDiagnostics);");
|
||||
}
|
||||
|
|
@ -267,11 +276,21 @@ public sealed class GameWindowSlice8BoundaryTests
|
|||
"Window.Create(options)");
|
||||
AssertAppearsInOrder(
|
||||
load,
|
||||
"_runtimeSettings.ApplyStartup(",
|
||||
"if (DevToolsEnabled)",
|
||||
"new SettingsDevToolsCompositionPhase(",
|
||||
"this).Compose(platform, hostInputCamera, contentEffectsAudio);",
|
||||
"TerrainAtlas.Build(",
|
||||
"_runtimeSettings.BindRuntimeTargets(",
|
||||
"_liveSessionHost.Start(_options)");
|
||||
string settingsPhase = File.ReadAllText(Path.Combine(
|
||||
FindRepoRoot(),
|
||||
"src",
|
||||
"AcDream.App",
|
||||
"Composition",
|
||||
"SettingsDevToolsComposition.cs"));
|
||||
AssertAppearsInOrder(
|
||||
settingsPhase,
|
||||
"_dependencies.Settings.ApplyStartup(_dependencies.StartupTarget);",
|
||||
"_dependencies.DevTools is { } optional");
|
||||
AssertAppearsInOrder(
|
||||
shutdown,
|
||||
"_runtimeSettings.UnbindViewModel()",
|
||||
|
|
|
|||
|
|
@ -81,6 +81,31 @@ public sealed class RuntimeSettingsControllerTests
|
|||
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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -12,6 +12,20 @@ namespace AcDream.UI.Abstractions.Tests.Panels.Chat;
|
|||
/// </summary>
|
||||
public sealed class ChatVMLastTellSenderTests
|
||||
{
|
||||
[Fact]
|
||||
public void DisposeDetachesTranscriptSubscriptionExactlyOnce()
|
||||
{
|
||||
var log = new ChatLog();
|
||||
var vm = new ChatVM(log);
|
||||
log.OnTellReceived("Before", "ping", 0x5000_0001u);
|
||||
|
||||
vm.Dispose();
|
||||
vm.Dispose();
|
||||
log.OnTellReceived("After", "pong", 0x5000_0002u);
|
||||
|
||||
Assert.Equal("Before", vm.LastIncomingTellSender);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LastIncomingTellSender_StartsNull()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -14,6 +14,22 @@ namespace AcDream.UI.Abstractions.Tests.Panels.Debug;
|
|||
/// </summary>
|
||||
public sealed class DebugVMTests
|
||||
{
|
||||
[Fact]
|
||||
public void DisposeDetachesEveryCombatSubscriptionExactlyOnce()
|
||||
{
|
||||
var combat = new CombatState();
|
||||
DebugVM vm = NewVm(combat);
|
||||
combat.OnKillerNotification("first", 1);
|
||||
Assert.Single(vm.CombatEvents);
|
||||
|
||||
vm.Dispose();
|
||||
vm.Dispose();
|
||||
combat.OnKillerNotification("second", 2);
|
||||
combat.OnAttackDone(3, 4);
|
||||
|
||||
Assert.Single(vm.CombatEvents);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build a minimal <see cref="DebugVM"/> with safe defaults for every
|
||||
/// constructor source. Tests that don't care about a particular source
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue