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:
parent
db4426d5ef
commit
844cf092a1
48 changed files with 227 additions and 6201 deletions
|
|
@ -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.");
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 =>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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\"");
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)));
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 > 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}");
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,3 @@
|
|||
using AcDream.App.Studio;
|
||||
using AcDream.App.UI;
|
||||
using AcDream.App.UI.Layout;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
using AcDream.App.Studio;
|
||||
using AcDream.App.UI;
|
||||
using AcDream.App.UI.Layout;
|
||||
using System.Numerics;
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue