acdream/tests/AcDream.App.Tests/Rendering/PaperdollFramePresenterTests.cs
Erik 9d7df1bfc5 refactor(render): compose render frame orchestrator
Move the accepted draw transaction and failure recovery behind typed frame-phase owners so GameWindow only supplies immutable frame input. Preserve retail draw order, make ImGui/bootstrap shutdown ownership explicit, and restore exact text-render GL state on failures.\n\nCo-authored-by: Codex <codex@openai.com>
2026-07-22 08:03:49 +02:00

256 lines
8.5 KiB
C#

using System.Numerics;
using System.Reflection;
using AcDream.App.Rendering;
using AcDream.App.Input;
using AcDream.Core.World;
namespace AcDream.App.Tests.Rendering;
public sealed class PaperdollFramePresenterTests
{
[Fact]
public void HiddenView_DoesNotBuildOrRender()
{
var renderer = new RecordingRenderer();
var view = new RecordingView { Visible = false };
var factory = new RecordingFactory();
var presenter = new PaperdollFramePresenter(renderer, view, factory);
presenter.Render();
Assert.True(presenter.IsDirty);
Assert.Equal(0, factory.BuildCount);
Assert.Equal(0, renderer.RenderCount);
Assert.Empty(view.TextureHandles);
}
[Fact]
public void FirstVisibleFrame_BuildsThenRendersAndPublishesTexture()
{
var doll = CreateDoll();
var renderer = new RecordingRenderer { TextureHandle = 91u };
var view = new RecordingView { Width = 240, Height = 320 };
var factory = new RecordingFactory { Doll = doll };
var presenter = new PaperdollFramePresenter(renderer, view, factory);
presenter.Render();
Assert.False(presenter.IsDirty);
Assert.Equal(1, factory.BuildCount);
Assert.Same(doll, renderer.Dolls.Single());
Assert.Equal([(240, 320)], renderer.RenderSizes);
Assert.Equal([91u], view.TextureHandles);
}
[Fact]
public void CleanFrame_ReusesDollUntilMarkedDirty()
{
var renderer = new RecordingRenderer();
var view = new RecordingView();
var factory = new RecordingFactory
{
Doll = CreateDoll(),
};
var presenter = new PaperdollFramePresenter(renderer, view, factory);
presenter.Render();
presenter.Render();
presenter.MarkDirty();
presenter.Render();
Assert.Equal(2, factory.BuildCount);
Assert.Equal(2, renderer.Dolls.Count);
Assert.Equal(3, renderer.RenderCount);
Assert.False(presenter.IsDirty);
}
private static WorldEntity CreateDoll() => new()
{
Id = 42u,
SourceGfxObjOrSetupId = 0x02000001u,
Position = Vector3.Zero,
Rotation = Quaternion.Identity,
MeshRefs = Array.Empty<MeshRef>(),
};
[Fact]
public void MissingPlayer_ClearsOldDollPublishesEmptyTextureAndRetries()
{
var renderer = new RecordingRenderer { TextureHandle = 0u };
var view = new RecordingView();
var factory = new RecordingFactory { CanBuild = false };
var presenter = new PaperdollFramePresenter(renderer, view, factory);
presenter.Render();
presenter.Render();
Assert.True(presenter.IsDirty);
Assert.Equal(2, factory.BuildCount);
Assert.Equal(2, renderer.Dolls.Count);
Assert.All(renderer.Dolls, Assert.Null);
Assert.Equal(2, renderer.RenderCount);
Assert.Equal([0u, 0u], view.TextureHandles);
}
[Fact]
public void PresenterAndProductionHelpersHaveNoDirectWindowOrDelegateBackReference()
{
Type[] owners =
[
typeof(PaperdollFramePresenter),
typeof(RetailPaperdollFrameView),
typeof(PaperdollInventoryVisibility),
typeof(LivePaperdollEntityLookup),
typeof(RetailPaperdollDollFactory),
typeof(RetailPaperdollPoseApplicator),
];
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 => field.FieldType == typeof(AcDream.App.UI.RetailUiRuntime));
Assert.DoesNotContain(
fields,
field => typeof(Delegate).IsAssignableFrom(field.FieldType));
}
}
[Fact]
public void RetailFactory_MissingOrEmptyCurrentPlayerRetriesWithoutApplyingPose()
{
var entities = new RecordingEntityLookup();
var identity = new LocalPlayerIdentityState { ServerGuid = 7u };
var pose = new RecordingPoseApplicator();
var factory = new RetailPaperdollDollFactory(entities, identity, pose);
Assert.False(factory.TryBuild(out WorldEntity? missing));
Assert.Null(missing);
entities.Entities[7u] = CreateDoll();
Assert.False(factory.TryBuild(out WorldEntity? empty));
Assert.Null(empty);
Assert.Empty(pose.Applications);
}
[Fact]
public void RetailFactory_ResolvesCurrentIdentityAndClonesLiveAppearance()
{
var firstMesh = new MeshRef(0x01000001u, Matrix4x4.Identity)
{
SurfaceOverrides = new Dictionary<uint, uint> { [2] = 0x08000001u },
};
var secondMesh = new MeshRef(
0x01000002u,
Matrix4x4.CreateTranslation(1f, 2f, 3f));
var first = CreatePlayer(0x02000001u, firstMesh);
var second = CreatePlayer(0x02000002u, secondMesh);
var entities = new RecordingEntityLookup
{
Entities =
{
[10u] = first,
[20u] = second,
},
};
var identity = new LocalPlayerIdentityState { ServerGuid = 10u };
var pose = new RecordingPoseApplicator();
var factory = new RetailPaperdollDollFactory(entities, identity, pose);
Assert.True(factory.TryBuild(out WorldEntity? firstDoll));
identity.ServerGuid = 20u;
Assert.True(factory.TryBuild(out WorldEntity? secondDoll));
Assert.NotNull(firstDoll);
Assert.NotNull(secondDoll);
Assert.Equal(0x02000001u, firstDoll.SourceGfxObjOrSetupId);
Assert.Equal(0x02000002u, secondDoll.SourceGfxObjOrSetupId);
Assert.NotSame(first.MeshRefs, firstDoll.MeshRefs);
Assert.Equal(firstMesh.GfxObjId, firstDoll.MeshRefs[0].GfxObjId);
Assert.Same(firstMesh.SurfaceOverrides, firstDoll.MeshRefs[0].SurfaceOverrides);
Assert.Equal(
[(firstDoll, 0x02000001u), (secondDoll, 0x02000002u)],
pose.Applications);
Assert.Equal([10u, 20u], entities.RequestedGuids);
}
private static WorldEntity CreatePlayer(uint setupId, MeshRef mesh) => new()
{
Id = setupId,
SourceGfxObjOrSetupId = setupId,
Position = Vector3.Zero,
Rotation = Quaternion.Identity,
MeshRefs = new List<MeshRef> { mesh },
};
private sealed class RecordingRenderer : IPaperdollDollRenderer
{
public uint TextureHandle { get; init; }
public List<WorldEntity?> Dolls { get; } = [];
public List<(int Width, int Height)> RenderSizes { get; } = [];
public int RenderCount => RenderSizes.Count;
public void SetDoll(WorldEntity? doll) => Dolls.Add(doll);
public uint Render(int width, int height)
{
RenderSizes.Add((width, height));
return TextureHandle;
}
}
private sealed class RecordingView : IPaperdollFrameView
{
public bool Visible { get; init; } = true;
public int Width { get; init; } = 100;
public int Height { get; init; } = 120;
public List<uint> TextureHandles { get; } = [];
public bool TryGetVisibleSize(out int width, out int height)
{
width = Width;
height = Height;
return Visible;
}
public void SetTextureHandle(uint textureHandle) =>
TextureHandles.Add(textureHandle);
}
private sealed class RecordingFactory : IPaperdollDollFactory
{
public bool CanBuild { get; init; } = true;
public WorldEntity? Doll { get; init; }
public int BuildCount { get; private set; }
public bool TryBuild(out WorldEntity? doll)
{
BuildCount++;
doll = Doll;
return CanBuild;
}
}
private sealed class RecordingEntityLookup : IPaperdollEntityLookup
{
public Dictionary<uint, WorldEntity> Entities { get; } = [];
public List<uint> RequestedGuids { get; } = [];
public bool TryGet(uint serverGuid, out WorldEntity player)
{
RequestedGuids.Add(serverGuid);
return Entities.TryGetValue(serverGuid, out player!);
}
}
private sealed class RecordingPoseApplicator : IPaperdollPoseApplicator
{
public List<(WorldEntity Doll, uint SetupId)> Applications { get; } = [];
public void Apply(WorldEntity doll, uint setupId) =>
Applications.Add((doll, setupId));
}
}