using System.Reflection; using AcDream.App.Rendering; namespace AcDream.App.Tests.Rendering; public sealed class RenderFrameOrchestratorTests { private static readonly RenderFrameInput Input = new( DeltaSeconds: 1.0 / 60.0, ViewportWidth: 1920, ViewportHeight: 1080); [Theory] [InlineData(12, 40, true, false, false)] [InlineData(0, 0, false, false, false)] [InlineData(0, 0, false, true, false)] [InlineData(4, 17, true, false, true)] public void Render_PreservesOuterOrderAcrossWorldAndPresentationOutcomes( int visibleLandblocks, int totalLandblocks, bool normalWorldDrawn, bool portalViewportDrawn, bool screenshotCaptured) { var calls = new List(); var phases = new RecordingPhases(calls) { World = new WorldRenderFrameOutcome( visibleLandblocks, totalLandblocks, normalWorldDrawn), Presentation = new PrivatePresentationFrameOutcome( portalViewportDrawn, screenshotCaptured), }; var orchestrator = Create(phases); RenderFrameOutcome outcome = orchestrator.Render(Input); Assert.Equal( ["gpu-begin", "resources", "world", "presentation", "diagnostics", "gpu-end"], calls); Assert.Equal(phases.World, outcome.World); Assert.Equal(phases.Presentation, outcome.Presentation); Assert.Equal( [ ("resources", Input), ("world", Input), ("presentation", Input), ("diagnostics", Input), ], phases.ObservedInputs); Assert.Equal(phases.World, phases.ObservedWorld); Assert.Equal(outcome, phases.ObservedOutcome); } [Fact] public void BeginFailure_DoesNotAttemptAnyPhaseOrClose() { var calls = new List(); var expected = new InvalidOperationException("begin"); var phases = new RecordingPhases(calls) { FailurePoint = "gpu-begin", Failure = expected, }; InvalidOperationException actual = Assert.Throws( () => Create(phases).Render(Input)); Assert.Same(expected, actual); Assert.Equal(["gpu-begin"], calls); } [Theory] [InlineData("resources")] [InlineData("world")] [InlineData("presentation")] [InlineData("diagnostics")] public void RenderFailure_ClosesExactlyOnceAndPropagates(string failurePoint) { var calls = new List(); var expected = new InvalidOperationException(failurePoint); var phases = new RecordingPhases(calls) { FailurePoint = failurePoint, Failure = expected, }; InvalidOperationException actual = Assert.Throws( () => Create(phases).Render(Input)); Assert.Same(expected, actual); Assert.Equal(ExpectedFailureCalls(failurePoint), calls); Assert.Equal( ExpectedPhaseInputs(failurePoint), phases.ObservedInputs); } [Fact] public void CloseOnlyFailure_PropagatesDirectly() { var calls = new List(); var expected = new InvalidOperationException("close"); var phases = new RecordingPhases(calls) { CloseFailure = expected, }; InvalidOperationException actual = Assert.Throws( () => Create(phases).Render(Input)); Assert.Same(expected, actual); Assert.Equal( ["gpu-begin", "resources", "world", "presentation", "diagnostics", "gpu-end"], calls); } [Theory] [InlineData("resources")] [InlineData("world")] [InlineData("presentation")] [InlineData("diagnostics")] public void RenderAndCloseFailure_AreAggregatedInCausalOrder(string failurePoint) { var calls = new List(); var renderFailure = new InvalidOperationException("render"); var closeFailure = new InvalidOperationException("close"); var phases = new RecordingPhases(calls) { FailurePoint = failurePoint, Failure = renderFailure, CloseFailure = closeFailure, }; AggregateException actual = Assert.Throws( () => Create(phases).Render(Input)); Assert.Equal( "Rendering failed and the in-flight GPU frame could not be closed.", actual.Message.Split(" (")[0]); Assert.Equal(2, actual.InnerExceptions.Count); Assert.Same(renderFailure, actual.InnerExceptions[0]); Assert.Same(closeFailure, actual.InnerExceptions[1]); Assert.Equal(ExpectedFailureCalls(failurePoint), calls); Assert.Equal( ExpectedPhaseInputs(failurePoint), phases.ObservedInputs); } [Fact] public void RenderAndRecoveryFailure_AreAggregatedBeforeGpuClose() { var calls = new List(); var renderFailure = new InvalidOperationException("render"); var recoveryFailure = new InvalidOperationException("recovery"); var phases = new RecordingPhases(calls) { FailurePoint = "world", Failure = renderFailure, RecoveryFailure = recoveryFailure, }; AggregateException actual = Assert.Throws( () => Create(phases).Render(Input)); Assert.Equal(2, actual.InnerExceptions.Count); Assert.Same(renderFailure, actual.InnerExceptions[0]); Assert.Same(recoveryFailure, actual.InnerExceptions[1]); Assert.Equal( ["gpu-begin", "resources", "world", "recovery-abort", "gpu-end"], calls); } [Fact] public void RenderRecoveryAndCloseFailures_AllRemainObservableInCausalOrder() { var calls = new List(); var renderFailure = new InvalidOperationException("render"); var recoveryFailure = new InvalidOperationException("recovery"); var closeFailure = new InvalidOperationException("close"); var phases = new RecordingPhases(calls) { FailurePoint = "presentation", Failure = renderFailure, RecoveryFailure = recoveryFailure, CloseFailure = closeFailure, }; AggregateException actual = Assert.Throws( () => Create(phases).Render(Input)); Assert.Equal(3, actual.InnerExceptions.Count); Assert.Same(renderFailure, actual.InnerExceptions[0]); Assert.Same(recoveryFailure, actual.InnerExceptions[1]); Assert.Same(closeFailure, actual.InnerExceptions[2]); Assert.Equal( [ "gpu-begin", "resources", "world", "presentation", "recovery-abort", "gpu-end", ], calls); } [Fact] public void Constructor_RejectsEveryMissingRequiredOwner() { var phases = new RecordingPhases([]); Assert.Throws(() => new RenderFrameOrchestrator( null!, phases, phases, phases, phases, phases)); Assert.Throws(() => new RenderFrameOrchestrator( phases, null!, phases, phases, phases, phases)); Assert.Throws(() => new RenderFrameOrchestrator( phases, phases, null!, phases, phases, phases)); Assert.Throws(() => new RenderFrameOrchestrator( phases, phases, phases, null!, phases, phases)); Assert.Throws(() => new RenderFrameOrchestrator( phases, phases, phases, phases, null!, phases)); Assert.Throws(() => new RenderFrameOrchestrator( phases, phases, phases, phases, phases, null!)); } [Fact] public void Orchestrator_UsesOnlyTheExplicitTypedOwnerGraph() { Type[] expectedFieldTypes = [ typeof(IRenderFrameLifetime), typeof(IRenderFrameResourcePhase), typeof(IWorldSceneFramePhase), typeof(IPrivatePresentationFramePhase), typeof(IRenderFrameDiagnosticsPhase), typeof(IRenderFrameFailureRecovery), ]; FieldInfo[] fields = typeof(RenderFrameOrchestrator).GetFields( BindingFlags.Instance | BindingFlags.NonPublic); Assert.Equal( expectedFieldTypes.OrderBy(type => type.FullName), fields.Select(field => field.FieldType).OrderBy(type => type.FullName)); Assert.All(fields, field => { Assert.NotEqual(typeof(GameWindow), field.FieldType); Assert.False(typeof(Delegate).IsAssignableFrom(field.FieldType)); }); Assert.All( expectedFieldTypes, contract => Assert.False(contract.IsAssignableFrom(typeof(GameWindow)))); foreach (Type contract in expectedFieldTypes) { foreach (MethodInfo method in contract.GetMethods()) { Assert.True( method.ReturnType == typeof(void) || method.ReturnType.IsValueType, $"{contract.Name}.{method.Name} returned owner-like type " + method.ReturnType.FullName); Assert.All( method.GetParameters(), parameter => Assert.True( parameter.ParameterType.IsValueType, $"{contract.Name}.{method.Name} accepted owner-like type " + parameter.ParameterType.FullName)); } } Assert.True(typeof(IRenderFrameLifetime).IsAssignableFrom( typeof(GpuFrameFlightController))); } [Fact] public void FrameContracts_AreDataOnlyValuesWithoutOwnerOrDelegateReferences() { Type[] contracts = [ typeof(RenderFrameInput), typeof(WorldRenderFrameOutcome), typeof(PrivatePresentationFrameOutcome), typeof(RenderFrameOutcome), ]; foreach (Type contract in contracts) { Assert.True(contract.IsValueType); Assert.All( contract.GetFields( BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic), field => { Assert.True( field.FieldType.IsValueType, $"{contract.Name}.{field.Name} retained owner-like type " + field.FieldType.FullName); }); } } private static string[] ExpectedFailureCalls(string failurePoint) => failurePoint switch { "resources" => ["gpu-begin", "resources", "recovery-abort", "gpu-end"], "world" => ["gpu-begin", "resources", "world", "recovery-abort", "gpu-end"], "presentation" => [ "gpu-begin", "resources", "world", "presentation", "recovery-abort", "gpu-end", ], "diagnostics" => [ "gpu-begin", "resources", "world", "presentation", "diagnostics", "recovery-abort", "gpu-end", ], _ => throw new ArgumentOutOfRangeException(nameof(failurePoint)), }; private static (string Phase, RenderFrameInput Input)[] ExpectedPhaseInputs( string failurePoint) { string[] phases = failurePoint switch { "resources" => ["resources"], "world" => ["resources", "world"], "presentation" => ["resources", "world", "presentation"], "diagnostics" => ["resources", "world", "presentation", "diagnostics"], _ => throw new ArgumentOutOfRangeException(nameof(failurePoint)), }; return phases.Select(phase => (phase, Input)).ToArray(); } private static RenderFrameOrchestrator Create(RecordingPhases phases) => new(phases, phases, phases, phases, phases, phases); private sealed class RecordingPhases : IRenderFrameLifetime, IRenderFrameResourcePhase, IWorldSceneFramePhase, IPrivatePresentationFramePhase, IRenderFrameDiagnosticsPhase, IRenderFrameFailureRecovery { private readonly List _calls; public RecordingPhases(List calls) { _calls = calls; } public string? FailurePoint { get; init; } public Exception? Failure { get; init; } public Exception? CloseFailure { get; init; } public Exception? RecoveryFailure { get; init; } public WorldRenderFrameOutcome World { get; init; } = new(7, 19, true); public PrivatePresentationFrameOutcome Presentation { get; init; } = new(false, false); public List<(string Phase, RenderFrameInput Input)> ObservedInputs { get; } = []; public WorldRenderFrameOutcome ObservedWorld { get; private set; } public RenderFrameOutcome ObservedOutcome { get; private set; } public void BeginFrame() => Record("gpu-begin"); public void EndFrame() { _calls.Add("gpu-end"); if (CloseFailure is not null) throw CloseFailure; } public void AbortFrame() { _calls.Add("recovery-abort"); if (RecoveryFailure is not null) throw RecoveryFailure; } public void Prepare(RenderFrameInput input) { ObservedInputs.Add(("resources", input)); Record("resources"); } public WorldRenderFrameOutcome Render(RenderFrameInput input) { ObservedInputs.Add(("world", input)); Record("world"); return World; } public PrivatePresentationFrameOutcome Render( RenderFrameInput input, WorldRenderFrameOutcome world) { ObservedInputs.Add(("presentation", input)); ObservedWorld = world; Record("presentation"); return Presentation; } public void Publish(RenderFrameInput input, RenderFrameOutcome outcome) { ObservedInputs.Add(("diagnostics", input)); ObservedOutcome = outcome; Record("diagnostics"); } private void Record(string call) { _calls.Add(call); if (FailurePoint == call && Failure is not null) throw Failure; } } }