feat(render): Campaign V slice V11 commit 1 - delete ImGui, Studio, and the DevTools frontend

The ImGui developer-tools stack (AcDream.UI.ImGui), UI Studio
(src/AcDream.App/Studio), and the DevToolsFramePresenter/
SettingsDevToolsCompositionPhase ImGui composition machinery are removed.
Vulkan never composed a DevTools frontend (DevToolsEnabled already forced
false whenever the backend was Vulkan); this commit makes that permanent by
deleting the only implementation rather than leaving a dead branch behind.

What moved: Studio/SampleData.cs is a live production dependency
(InteractionRetainedUiComposition's character-sheet fallback, plus three
UI.Layout test files) - git mv'd to src/AcDream.App/UI/Layout/SampleData.cs,
namespace AcDream.App.UI.Layout, and trimmed to the SampleCharacter API that
is actually still called (BuildObjectTable/AddItem/AddEquipped/the item-guid
and icon constants had zero callers left once the Studio fixture provider
that used them was deleted).

What survives as backend-neutral seams, per the tests that still exercise
them: IDevToolsFrameLifecycle (moved into RenderFramePreparationController.cs,
now always bound to null), IFramebufferDevToolsTarget/FramebufferDevToolsBinding
in FramebufferResizeController.cs (its concrete DevToolsFramebufferTarget
adapter is deleted), and IDevToolsGameplayCommands in
GameplayInputCommandController.cs (DevToolsGameplayCommands becomes a
documented no-op instead of forwarding to the deleted presenter). A follow-up
re-homes Settings/Debug onto the retained UI through IPanelRenderer; until
then keybind remapping falls back to editing keybinds.json.

DevToolsEnabled is now `private const bool DevToolsEnabled = false`.
RuntimeOptions.DevTools is unchanged and still reaches VulkanGraphicsContext
for the optional debug-utils extensions; Program.cs now logs one line when
ACDREAM_DEVTOOLS=1 explaining that the ImGui UI is gone and the flag is
Vulkan-only now.

Removed: AcDream.UI.ImGui (project + ImGui.NET/Silk.NET.OpenGL.Extensions.ImGui
package refs), src/AcDream.App/Studio (minus SampleData.cs),
DevToolsFramePresenter.cs and everything only it constructed
(ISettingsDevToolsCompositionFactory, RetailSettingsDevToolsCompositionFactory,
DevToolsCompositionOwner, IGameWindowSettingsDevToolsPublication,
SettingsDevToolsOptionalDependencies, the "developer tools" shutdown-ledger
stage and its DevTools-typed fields on IngressShutdownRoots/
RenderShutdownRoots), the ui-studio Program.cs verb, and the cimgui native
manifest entries in GraphicalHostPlatformServices. GameWindow.cs's DevTools
composition branch, its _vitalsVm/_debugVm/_devToolsComposition/
_devToolsFramePresenter/_devToolsCommandBus fields, and every settingsDevTools
.DevTools?.* access across FrameRootComposition.cs/SessionPlayerComposition.cs
are gone with it.

Build green; complete Release solution suite 8,830 / 5 skips (App Tests
4,097/3 skips run standalone - one #250-family zero-allocation test flakes
under the full parallel `dotnet test AcDream.slnx` run, a pre-existing,
documented class unrelated to this change).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-28 23:56:04 +02:00
parent db4426d5ef
commit 844cf092a1
48 changed files with 227 additions and 6201 deletions

View file

@ -1,526 +0,0 @@
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.Runtime.Gameplay;
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.False(
fixture.Dependencies.Runtime.CaptureOwnership().IsDisposeRequested);
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!,
null,
null,
null,
null,
_dispatcher,
camera,
null);
Platform = new GameWindowPlatformResult<GameWindowGraphics, IInputContext>(TestGameWindowGraphics.OpenGl, null!);
Content = (ContentEffectsAudioResult)RuntimeHelpers.GetUninitializedObject(
typeof(ContentEffectsAudioResult));
Dependencies = new SettingsDevToolsDependencies(
DispatchProxy.Create<IView, ViewProxy>(),
Settings,
Startup,
new HostQuiescenceGate(),
GameRuntimeTestFactory.Create(),
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<GameWindowGraphics, 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()
{
Dependencies.Runtime.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.");
}
}

View file

@ -196,8 +196,7 @@ public sealed class WorldRenderCompositionTests
new GameWindowPlatformResult<GameWindowGraphics, IInputContext>(TestGameWindowGraphics.OpenGl, null!),
Content,
new SettingsDevToolsResult(
QualitySettings.From(QualityPreset.High),
null));
QualitySettings.From(QualityPreset.High)));
}
private sealed class RenderLifetime(TerrainAtlas atlas)

View file

@ -12,7 +12,9 @@ public sealed class GraphicalHostPlatformServicesTests
Assert.NotNull(platform.Paths);
Assert.NotNull(platform.FramePacingWaiters);
Assert.Equal(3, platform.NativeDependencies.Count);
// Campaign V slice V11 removed the ImGui developer-tools frontend and
// its cimgui native dependency, leaving window/input + audio.
Assert.Equal(2, platform.NativeDependencies.Count);
Assert.All(
platform.NativeDependencies,
dependency =>

View file

@ -1,455 +0,0 @@
using System.Numerics;
using System.Reflection;
using AcDream.App.Diagnostics;
using AcDream.App.Net;
using AcDream.App.Rendering;
using AcDream.UI.Abstractions;
namespace AcDream.App.Tests.Rendering;
public sealed class DevToolsFramePresenterTests
{
[Fact]
public void CommandBusOwnedBindingReleasesExactlyAndAllowsRebind()
{
var source = new DevToolsCommandBusSource();
var first = new TestUiSessionTarget();
var second = new TestUiSessionTarget();
IDisposable firstBinding = source.BindOwned(first);
Assert.Same(first.Commands, source.Current);
Assert.Throws<InvalidOperationException>(() => source.BindOwned(second));
firstBinding.Dispose();
using IDisposable secondBinding = source.BindOwned(second);
firstBinding.Dispose();
Assert.Same(second.Commands, source.Current);
}
private sealed class TestUiSessionTarget : ILiveUiSessionTarget
{
public bool IsInWorld => false;
public AcDream.Core.Net.WorldSession? CurrentSession => null;
public ICommandBus Commands { get; } = new RecordingCommandBus();
}
[Fact]
public void Frame_PreservesBeginThenMenuPanelsAndDrawDataOrder()
{
var calls = new List<string>();
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<string>();
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<string>();
var backend = new RecordingBackend(calls)
{
DrawFailure = new InvalidOperationException("draw"),
};
var presenter = Create(backend);
presenter.BeginFrame(0.1f);
Assert.Throws<InvalidOperationException>(() =>
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<string>();
var backend = new RecordingBackend(calls)
{
BeginFailure = new InvalidOperationException("begin"),
};
var presenter = Create(backend);
Assert.Throws<InvalidOperationException>(() => 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<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; } = [];
public List<PanelContext> Contexts { get; } = [];
public List<LayoutCallWithCondition> 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>(T command) where T : notnull
{
}
}
private sealed class RecordingPanels : IDevToolsPanelSet
{
private readonly HashSet<DevToolsPanelKind> _visible = [];
public bool HasSettings { get; init; } = true;
public List<DevToolsPanelKind> Toggles { get; } = [];
public List<string> 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);
}
}

View file

@ -108,13 +108,6 @@ public sealed class GameWindowRenderLeafCompositionTests
Assert.DoesNotContain(identifier, source, StringComparison.Ordinal);
Assert.Contains("new PaperdollFramePresenter(", LivePresentationSource());
string settingsComposition = File.ReadAllText(Path.Combine(
FindRepoRoot(),
"src",
"AcDream.App",
"Composition",
"SettingsDevToolsComposition.cs"));
Assert.Contains("new DevToolsFramePresenter(", settingsComposition);
string framePhase = FrameRootSource();
Assert.Contains("new RenderFrameResourceController(", framePhase);
Assert.Contains("new RenderWeatherFrameController(", framePhase);
@ -131,23 +124,21 @@ public sealed class GameWindowRenderLeafCompositionTests
}
[Fact]
public void Shutdown_PreservesBorrowedDevtoolsLifetimeAndDrainsGpuBeforeFrontends()
public void Shutdown_DrainsGpuBeforeFrontendsAndPreservesFrameBorrowerOrder()
{
// Campaign V slice V11 removed the ImGui developer-tools frontend and
// its "developer tools" shutdown stage entry along with it; this test
// used to pin that entry's position and is now renamed to pin what
// survives it.
string source = GameWindowLifetimeSource();
AssertAppearsInOrder(
source,
"new ResourceShutdownStage(\"submitted GPU work\"",
"new ResourceShutdownStage(\"render frontends\"",
"Hard(\"developer tools\", () => DisposeDevToolsFrontend(render.DevTools))",
"Hard(\"portal tunnel\"",
"Hard(\"paperdoll viewport\"",
"new ResourceShutdownStage(\"OpenGL context\"");
AssertAppearsInOrder(
source,
"private static void DisposeDevToolsFrontend(DevToolsCompositionOwner? owner)",
"owner.DisposeFrontend()",
"if (!owner.IsFrontendDisposalComplete)");
AssertAppearsInOrder(
source,
"new ResourceShutdownStage(\"frame borrowers\"",
@ -161,7 +152,7 @@ public sealed class GameWindowRenderLeafCompositionTests
AssertAppearsInOrder(
source,
"frame.FrameGraphPublication?.Dispose()",
"Hard(\"developer tools\", () => DisposeDevToolsFrontend(render.DevTools))",
"new ResourceShutdownStage(\"render frontends\"",
"new ResourceShutdownStage(\"input context\"",
"platform.Input?.Dispose()",
"new ResourceShutdownStage(\"OpenGL context\"");

View file

@ -58,7 +58,7 @@ public sealed class GameWindowSlice8BoundaryTests
"new ContentEffectsAudioCompositionPhase(",
"this).Compose(platformResult, hostInputCamera),",
"new SettingsDevToolsCompositionPhase(",
"this).Compose(platformResult, hostInputCamera, contentEffectsAudio);",
".Compose(platformResult, hostInputCamera, contentEffectsAudio),",
"new InteractionRetainedUiCompositionPhase(",
"this).Compose(",
"new LivePresentationCompositionPhase(",
@ -197,17 +197,6 @@ 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 =");
string sessionPhase = File.ReadAllText(Path.Combine(
FindRepoRoot(),
"src",
@ -349,7 +338,7 @@ public sealed class GameWindowSlice8BoundaryTests
load,
"GameWindowCompositionPipeline.Run<",
"new SettingsDevToolsCompositionPhase(",
"this).Compose(platformResult, hostInputCamera, contentEffectsAudio);",
".Compose(platformResult, hostInputCamera, contentEffectsAudio),",
"new WorldRenderCompositionPhase(",
"new SessionPlayerCompositionPhase(",
"new FrameRootCompositionPhase(",
@ -371,10 +360,10 @@ public sealed class GameWindowSlice8BoundaryTests
"AcDream.App",
"Composition",
"SettingsDevToolsComposition.cs"));
AssertAppearsInOrder(
settingsPhase,
Assert.Contains(
"_dependencies.Settings.ApplyStartup(_dependencies.StartupTarget);",
"_dependencies.DevTools is { } optional");
settingsPhase,
StringComparison.Ordinal);
string worldPhase = File.ReadAllText(Path.Combine(
FindRepoRoot(),
"src",

View file

@ -162,9 +162,6 @@ public sealed class PrivatePresentationRendererTests
Assert.Contains(
typeof(AcDream.App.UI.UiElement),
FieldTypes(typeof(PaperdollInventoryVisibility)));
Assert.Contains(
typeof(AcDream.UI.Abstractions.Panels.Debug.DebugVM),
FieldTypes(typeof(DevToolsPanelSet)));
Assert.Contains(
typeof(AcDream.App.Settings.IRuntimeSettingsPreviewSource),
FieldTypes(typeof(RuntimeWorldFrameSettingsPreview)));

View file

@ -1,118 +0,0 @@
using AcDream.App.Studio;
namespace AcDream.App.Tests.Studio;
/// <summary>
/// Pure-math tests for the canvas → panel-local coordinate mapping used by
/// <see cref="StudioInspector.DrawCanvas"/>.
///
/// <para>No GL context required — we're just verifying the formula:
/// panel_local = (raw_mouse_screen) - (image_screen_top_left)</para>
///
/// <para>The image_screen_top_left is what ImGui.GetItemRectMin() returns after
/// ImGui.Image: the sub-window top-left + title-bar height + inner padding + any
/// scrolling. We model it as a constant offset in these tests.</para>
///
/// <para>V-flip: the image is drawn with uv0=(0,1) / uv1=(1,0) so GL's bottom-left
/// origin is flipped to top-left on screen. After the flip, screen Y=0 (top of image)
/// = panel Y=0 (top of the UI), so NO additional Y inversion is applied.</para>
/// </summary>
public class CanvasCoordMappingTests
{
// Simulates the mapping DrawCanvas performs:
// panel pixel = mouse_screen - image_rectMin
// Returns null when the result falls outside [0, width) x [0, height).
private static (int px, int py)? Map(
float mouseScreenX, float mouseScreenY,
float imageOriginX, float imageOriginY,
int panelWidth, int panelHeight)
{
int ix = (int)(mouseScreenX - imageOriginX);
int iy = (int)(mouseScreenY - imageOriginY);
if (ix < 0 || ix >= panelWidth || iy < 0 || iy >= panelHeight)
return null;
return (ix, iy);
}
[Fact]
public void TopLeft_of_image_maps_to_panel_origin()
{
// The canvas image starts at screen (300, 50) (after sub-window chrome).
// A click exactly at the image's screen top-left → panel (0, 0).
var result = Map(mouseScreenX: 300f, mouseScreenY: 50f,
imageOriginX: 300f, imageOriginY: 50f,
panelWidth: 1280, panelHeight: 720);
Assert.Equal((0, 0), result);
}
[Fact]
public void Interior_point_maps_correctly()
{
// Image origin at screen (300, 50). Mouse at screen (780, 230).
// Expected panel coord: (780-300, 230-50) = (480, 180).
var result = Map(mouseScreenX: 780f, mouseScreenY: 230f,
imageOriginX: 300f, imageOriginY: 50f,
panelWidth: 1280, panelHeight: 720);
Assert.Equal((480, 180), result);
}
[Fact]
public void Bottom_right_corner_maps_to_last_valid_pixel()
{
// Image is 1280×720, origin at screen (300, 50).
// Last pixel in bottom-right is panel (1279, 719) → screen (1579, 769).
var result = Map(mouseScreenX: 1579f, mouseScreenY: 769f,
imageOriginX: 300f, imageOriginY: 50f,
panelWidth: 1280, panelHeight: 720);
Assert.Equal((1279, 719), result);
}
[Fact]
public void Mouse_on_ImGui_chrome_above_image_returns_null()
{
// The ImGui window title-bar / padding is above rectMin, i.e. at screenY < 50.
// The mouse there should NOT produce a panel event.
var result = Map(mouseScreenX: 400f, mouseScreenY: 40f, // 10px above image origin
imageOriginX: 300f, imageOriginY: 50f,
panelWidth: 1280, panelHeight: 720);
Assert.Null(result);
}
[Fact]
public void Mouse_below_image_returns_null()
{
// screenY = 50 + 720 = 770 → iy = 720 which is >= panelHeight (720).
var result = Map(mouseScreenX: 400f, mouseScreenY: 770f,
imageOriginX: 300f, imageOriginY: 50f,
panelWidth: 1280, panelHeight: 720);
Assert.Null(result);
}
[Fact]
public void Y_is_not_inverted_after_vflip()
{
// Confirm the "no extra Y inversion" contract:
// The image is V-flipped in ImGui (uv0.Y=1, uv1.Y=0), so screen top row = panel Y=0.
// A click near the TOP of the image should give a SMALL panel Y, not a large one.
// Image origin at (300, 50). Click at (400, 55) → panel (100, 5). Y is small (near top).
var result = Map(mouseScreenX: 400f, mouseScreenY: 55f,
imageOriginX: 300f, imageOriginY: 50f,
panelWidth: 1280, panelHeight: 720);
Assert.Equal((100, 5), result);
// NOT (100, 715) — which would be the result if Y were incorrectly inverted.
Assert.True(result!.Value.py < 720 / 2, "Y near screen top should map to small panel Y, not near bottom");
}
[Fact]
public void LargeChrome_offset_is_fully_absorbed()
{
// Simulate a canvas sub-window with large chrome: ImGui title (20px) + padding (8px)
// puts the image origin at screenY = menuBar(22) + titleBar(20) + padding(8) = 50.
// Also a wide tree pane puts imageOriginX = 280 + padding.
// A click at screen (400, 110) with origin (290, 50) → panel (110, 60).
var result = Map(mouseScreenX: 400f, mouseScreenY: 110f,
imageOriginX: 290f, imageOriginY: 50f,
panelWidth: 1280, panelHeight: 720);
Assert.Equal((110, 60), result);
}
}

View file

@ -1,188 +0,0 @@
using AcDream.App.Studio;
using AcDream.App.UI;
namespace AcDream.App.Tests.Studio;
/// <summary>
/// Tests for <see cref="DumpLayout"/> — parsing the retail UI dump JSON and
/// building a <see cref="UiElement"/> tree from it.
///
/// These tests load the real dump file from the source tree
/// (<c>docs/research/2026-06-25-retail-ui-layout-dump.json</c>). The test
/// skips cleanly when the file is absent (should not happen in a normal dev
/// checkout, but guards against stripped CI machines).
/// </summary>
public class DumpLayoutTests
{
private static string DumpPath()
{
// Walk up from the test output directory to the solution root,
// mirroring ConformanceDats.SolutionRoot().
var dir = AppContext.BaseDirectory;
while (!string.IsNullOrEmpty(dir))
{
if (File.Exists(Path.Combine(dir, "AcDream.slnx")))
return Path.Combine(dir, "docs", "research",
"2026-06-25-retail-ui-layout-dump.json");
dir = Path.GetDirectoryName(dir);
}
// Fallback: try a relative path (won't find it but skip rather than throw)
return Path.Combine(AppContext.BaseDirectory,
"docs", "research", "2026-06-25-retail-ui-layout-dump.json");
}
private static (uint, int, int) NoTex(uint _) => (1u, 1, 1);
// ── Helpers ──────────────────────────────────────────────────────────
/// <summary>Depth-first search for an element with the given EventId.</summary>
private static UiElement? FindById(UiElement root, uint id)
{
if (root.EventId == id) return root;
foreach (var c in root.Children)
{
var found = FindById(c, id);
if (found is not null) return found;
}
return null;
}
/// <summary>Count the total elements in the tree (self + all descendants).</summary>
private static int CountAll(UiElement root)
{
int n = 1;
foreach (var c in root.Children) n += CountAll(c);
return n;
}
// ── Tests ─────────────────────────────────────────────────────────────
/// <summary>
/// Loading the "inventory" slug should succeed and the returned tree should
/// contain an element with EventId == 0x100001D5 (the doll viewport node)
/// and at least 40 elements in total.
/// </summary>
[Fact]
public void Load_Inventory_ReturnsTreeWithDollViewport()
{
var path = DumpPath();
if (!File.Exists(path))
return; // Skip: dump not available.
var root = DumpLayout.Load(path, "inventory", NoTex, out var err);
Assert.NotNull(root);
Assert.Null(err);
// The doll viewport element must appear somewhere in the tree.
const uint dollViewportId = 0x100001D5u;
var found = FindById(root!, dollViewportId);
Assert.NotNull(found);
// The full tree must be reasonably deep — 59 dump nodes → >= 40 elements.
int total = CountAll(root!);
Assert.True(total >= 40,
$"Expected >= 40 elements in inventory tree; got {total}");
}
/// <summary>
/// Loading an unknown slug must return null and a non-empty error string.
/// </summary>
[Fact]
public void Load_UnknownSlug_ReturnsNullWithError()
{
var path = DumpPath();
if (!File.Exists(path))
return; // Skip.
var root = DumpLayout.Load(path, "this_slug_does_not_exist", NoTex, out var err);
Assert.Null(root);
Assert.NotNull(err);
Assert.NotEmpty(err!);
}
/// <summary>
/// The root element's Left/Top should be (0,0) (the panel's rect offset has
/// been stripped so the tree sits at the window origin), and its Width/Height
/// should match the dump panel dimensions.
/// </summary>
[Fact]
public void Load_Inventory_RootAtOrigin()
{
var path = DumpPath();
if (!File.Exists(path))
return; // Skip.
var root = DumpLayout.Load(path, "inventory", NoTex, out _);
Assert.NotNull(root);
// Root always placed at (0,0) by DumpLayout (origin of the UiHost).
Assert.Equal(0f, root!.Left);
Assert.Equal(0f, root.Top);
// Width/Height come from the panel record in the dump.
Assert.True(root.Width > 0, "Root width must be > 0");
Assert.True(root.Height > 0, "Root height must be > 0");
}
/// <summary>
/// Children must use parent-relative coordinates (the dump rects are absolute;
/// DumpLayout subtracts the parent rect to produce parent-local offsets).
/// Verify that at least the direct children of the root have Left/Top values
/// that are NOT equal to the absolute rect they had in the dump (since the root
/// was at x>0 in screen space but we place it at 0,0).
/// </summary>
[Fact]
public void Load_Inventory_ChildrenAreParentRelative()
{
var path = DumpPath();
if (!File.Exists(path))
return; // Skip.
var root = DumpLayout.Load(path, "inventory", NoTex, out _);
Assert.NotNull(root);
// If the dump has children at absolute x>=500 but the root is at 0,
// a correct parent-relative placement will give children x < 500.
// (The inventory panel root is at absolute x=500; children in the dump
// also start at x=500 — after subtraction they should land near x=0.)
bool anyChildAtAbsoluteX = false;
foreach (var child in root!.Children)
{
if (child.Left >= 490f) // would indicate absolute not relative
{
anyChildAtAbsoluteX = true;
break;
}
}
Assert.False(anyChildAtAbsoluteX,
"Children appear to have absolute coords (Left >= 490) — " +
"DumpLayout must subtract the parent rect.");
}
/// <summary>
/// Every panel slug known in the dump must load without error.
/// This is a smoke test that the JSON parse + tree build does not
/// crash on any of the 26 panels.
/// </summary>
[Fact]
public void Load_AllSlugs_Succeed()
{
var path = DumpPath();
if (!File.Exists(path))
return; // Skip.
var slugs = UiDumpModel.ListSlugs(path);
Assert.True(slugs.Count >= 20,
$"Expected >= 20 panel slugs in dump; got {slugs.Count}");
foreach (var slug in slugs)
{
var root = DumpLayout.Load(path, slug, NoTex, out var err);
Assert.True(root is not null || err is not null,
$"Load('{slug}') returned both null root AND null error — one must be set.");
if (root is null)
Assert.Fail($"Slug '{slug}' failed with error: {err}");
}
}
}

View file

@ -1,160 +0,0 @@
using AcDream.App.Studio;
using AcDream.Core.Items;
namespace AcDream.App.Tests.Studio;
/// <summary>
/// Unit tests for <see cref="FixtureProvider"/> and <see cref="SampleData"/>.
/// These tests have NO GL/dat dependency — they only exercise the in-memory
/// ClientObjectTable population that FixtureProvider uses.
/// </summary>
public class FixtureProviderTests
{
/// <summary>
/// SampleData.BuildObjectTable() must place at least 6 items in the
/// player's main pack (ContainerId == PlayerGuid) and the player object
/// itself must exist with the right capacities.
/// </summary>
[Fact]
public void SampleTable_hasPackContents()
{
var t = SampleData.BuildObjectTable();
// Player object must exist.
var player = t.Get(SampleData.PlayerGuid);
Assert.NotNull(player);
Assert.Equal(102, player!.ItemsCapacity);
Assert.Equal(7, player.ContainersCapacity);
// At least 6 loose items must be in the main pack.
var contents = t.GetContents(SampleData.PlayerGuid);
Assert.True(contents.Count >= 6,
$"Expected >= 6 items in player pack; got {contents.Count}");
// Verify the first item has a non-zero IconId and a recognised Type.
var firstId = contents[0];
var first = t.Get(firstId);
Assert.NotNull(first);
Assert.NotEqual(0u, first!.IconId);
Assert.NotEqual(ItemType.None, first.Type);
}
/// <summary>
/// Spot-check that the sample table also seeds a sword-like item
/// (MeleeWeapon) and a piece of armor so icon resolution has
/// recognisable item types to work with.
/// </summary>
[Fact]
public void SampleTable_hasWeaponAndArmor()
{
var t = SampleData.BuildObjectTable();
var contents = t.GetContents(SampleData.PlayerGuid);
bool hasMelee = false, hasArmor = false;
foreach (var guid in contents)
{
var obj = t.Get(guid);
if (obj is null) continue;
if ((obj.Type & ItemType.MeleeWeapon) != 0) hasMelee = true;
if ((obj.Type & ItemType.Armor) != 0) hasArmor = true;
}
Assert.True(hasMelee, "Expected at least one MeleeWeapon in sample pack");
Assert.True(hasArmor, "Expected at least one Armor item in sample pack");
}
/// <summary>
/// Sample table must include at least 1 equipped item whose
/// CurrentlyEquippedLocation is non-None.
/// </summary>
[Fact]
public void SampleTable_hasEquippedItems()
{
var t = SampleData.BuildObjectTable();
bool anyEquipped = false;
foreach (var obj in t.Objects)
{
if (obj.CurrentlyEquippedLocation != EquipMask.None)
{ anyEquipped = true; break; }
}
Assert.True(anyEquipped, "Expected at least one equipped item in sample table");
}
/// <summary>
/// Sample table must include at least 2 side-bag containers in the
/// player's pack (ContainerId == PlayerGuid, Type has Container bit).
/// </summary>
[Fact]
public void SampleTable_hasSideBags()
{
var t = SampleData.BuildObjectTable();
int bagCount = 0;
foreach (var guid in t.GetContents(SampleData.PlayerGuid))
{
var obj = t.Get(guid);
if (obj is not null && (obj.Type & ItemType.Container) != 0)
bagCount++;
}
Assert.True(bagCount >= 2,
$"Expected >= 2 side-bag containers in player pack; got {bagCount}");
}
/// <summary>
/// Side bags must match the InventoryController filter: ItemType.Container OR ItemsCapacity &gt; 0.
/// This ensures they appear in the side-bag column even if the exact Type flag changes.
/// </summary>
[Fact]
public void SampleTable_sideBags_matchInventoryControllerFilter()
{
var t = SampleData.BuildObjectTable();
int bagCount = 0;
foreach (var guid in t.GetContents(SampleData.PlayerGuid))
{
var obj = t.Get(guid);
if (obj is null) continue;
// InventoryController.Populate line ~203: Type.HasFlag(Container) OR ItemsCapacity > 0
bool isBag = obj.Type.HasFlag(ItemType.Container) || obj.ItemsCapacity > 0;
if (isBag) bagCount++;
}
Assert.True(bagCount >= 2,
$"Expected >= 2 items matching the side-bag filter; got {bagCount}");
}
/// <summary>
/// Equipped items must BOTH (a) retain CurrentlyEquippedLocation != None after
/// seeding AND (b) appear in GetContents(PlayerGuid) so PaperdollController.Populate
/// can find them. These are the two conditions PaperdollController checks on every
/// equipped item (PaperdollController.Populate: ContainerId == playerGuid AND
/// CurrentlyEquippedLocation != None).
/// </summary>
[Fact]
public void SampleTable_equippedItems_retainLocationAndAreInContents()
{
var t = SampleData.BuildObjectTable();
var contents = new System.Collections.Generic.HashSet<uint>(t.GetContents(SampleData.PlayerGuid));
int equippedCount = 0;
foreach (var obj in t.Objects)
{
if (obj.CurrentlyEquippedLocation == EquipMask.None) continue;
equippedCount++;
// Must appear in GetContents so the controller can pick them up.
Assert.True(contents.Contains(obj.ObjectId),
$"Equipped item 0x{obj.ObjectId:X8} ('{obj.Name}', loc={obj.CurrentlyEquippedLocation}) " +
$"is NOT in GetContents(PlayerGuid). PaperdollController will miss it.");
// The equip location must survive the MoveItem call.
Assert.NotEqual(EquipMask.None, obj.CurrentlyEquippedLocation);
}
Assert.True(equippedCount >= 3,
$"Expected >= 3 equipped items in sample table; got {equippedCount}");
}
}

View file

@ -1,60 +0,0 @@
using AcDream.App.Studio;
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using DatReaderWriter;
using DatReaderWriter.Options;
namespace AcDream.App.Tests.Studio;
/// <summary>
/// Unit tests for <see cref="LayoutSource"/>. The dat-backed test skips cleanly
/// when the real dats are not present (CI / dev machines without AC installed).
/// </summary>
public class LayoutSourceTests
{
private static (uint handle, int width, int height) NoTex(uint _) => (1u, 1, 1);
/// <summary>Resolve the client dat directory, or null if unavailable (skip the test).</summary>
private static string? ResolveDatDir()
{
var fromEnv = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR");
if (!string.IsNullOrWhiteSpace(fromEnv) && Directory.Exists(fromEnv))
return fromEnv;
var def = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
"Documents", "Asheron's Call");
return Directory.Exists(def) ? def : null;
}
/// <summary>
/// Load the vitals LayoutDesc (0x2100006C) from the real dats and verify
/// that LayoutSource returns a non-null root with the layout id findable.
/// Skips when the dats are unavailable.
///
/// Note: the spec asserts FindElement(0x2100006Cu) — that is the layout dat
/// id, not an element id in the widget tree. The actual vitals root element id
/// is 0x100005F9 (confirmed from vitals_2100006C.json fixture). We assert
/// FindElement(0x100005F9) here which verifies the same integration path: the
/// layout was successfully loaded and the element dict was populated.
/// </summary>
[Fact]
public void LoadsDatLayout_byId()
{
var dir = ResolveDatDir();
if (dir is null)
return; // Skip: dats not available on this machine / CI.
using var dats = new DatCollection(dir, DatAccessType.Read);
var src = new LayoutSource(dats, NoTex, datFont: null);
var root = src.Load(new StudioOptions(dir, 0x2100006Cu, null));
// root must be non-null: Import found the LayoutDesc and built the tree.
Assert.NotNull(root);
Assert.NotNull(src.CurrentLayout);
// The vitals root element id is 0x100005F9 (layout dat id 0x2100006C ≠ element id).
// Asserting FindElement verifies the byId dict was populated by the importer.
Assert.NotNull(src.CurrentLayout!.FindElement(0x100005F9u));
Assert.Equal(LayoutSourceKind.DatLayout, src.Kind);
}
}

View file

@ -1,73 +0,0 @@
using AcDream.App.Studio;
using AcDream.App.UI;
namespace AcDream.App.Tests.Studio;
public class StudioWindowTests
{
[Fact]
public void FrameCloseGate_defersCloseUntilFrameCompletion()
{
var gate = new StudioFrameCloseGate();
int closeCount = 0;
gate.Request();
Assert.True(gate.IsRequested);
Assert.Equal(0, closeCount);
gate.CompleteFrame(() => closeCount++);
gate.CompleteFrame(() => closeCount++);
Assert.False(gate.IsRequested);
Assert.Equal(1, closeCount);
}
[Fact]
public void ParseMockup_doesNotDefaultToSinglePanelLayout()
{
var opts = StudioOptions.Parse(new[] { @"C:\fake-dats", "--mockup" });
Assert.True(opts.Mockup);
Assert.Null(opts.LayoutId);
Assert.Null(opts.DumpSlug);
Assert.Null(opts.MarkupPath);
}
[Fact]
public void ParseCapabilitySmokeOptions()
{
var opts = StudioOptions.Parse(
[
@"C:\fake-dats",
"--mockup",
"--capability-report",
"report.json",
"--audio-smoke",
]);
Assert.Equal("report.json", opts.CapabilityReportPath);
Assert.True(opts.AudioSmoke);
}
[Fact]
public void NormalizeSinglePanelRoot_movesDatPanelToCanvasOrigin()
{
var root = new UiPanel
{
Left = 500f,
Top = 138f,
Width = 300f,
Height = 362f,
Anchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right,
};
StudioWindow.NormalizeSinglePanelRoot(root);
Assert.Equal(0f, root.Left);
Assert.Equal(0f, root.Top);
Assert.Equal(300f, root.Width);
Assert.Equal(362f, root.Height);
Assert.Equal(AnchorEdges.None, root.Anchors);
}
}

View file

@ -1,4 +1,3 @@
using AcDream.App.Studio;
using AcDream.App.UI;
using AcDream.App.UI.Layout;

View file

@ -2,7 +2,6 @@ using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using AcDream.App.Studio;
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using DatReaderWriter;

View file

@ -1,4 +1,3 @@
using AcDream.App.Studio;
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using System.Numerics;

View file

@ -296,8 +296,7 @@ public sealed class RuntimeEntityOwnershipTests
string? ns = type.Namespace;
return ns?.StartsWith("AcDream.App", StringComparison.Ordinal) == true
|| ns?.StartsWith("AcDream.UI", StringComparison.Ordinal) == true
|| ns?.StartsWith("Silk.NET", StringComparison.Ordinal) == true
|| ns?.StartsWith("ImGuiNET", StringComparison.Ordinal) == true;
|| ns?.StartsWith("Silk.NET", StringComparison.Ordinal) == true;
}
private static bool IsGuidDictionary(FieldInfo field) =>

View file

@ -151,8 +151,7 @@ public sealed class RuntimeEntityDirectoryTests
Assert.DoesNotContain(exposed, type =>
type.Namespace?.StartsWith("AcDream.App", StringComparison.Ordinal) == true
|| type.Namespace?.StartsWith("Silk.NET", StringComparison.Ordinal) == true
|| type.Namespace?.StartsWith("ImGuiNET", StringComparison.Ordinal) == true);
|| type.Namespace?.StartsWith("Silk.NET", StringComparison.Ordinal) == true);
}
[Fact]