refactor(render): establish frame orchestration contract
This commit is contained in:
parent
f316bbd817
commit
7e4cfb37c3
4 changed files with 489 additions and 5 deletions
|
|
@ -0,0 +1,340 @@
|
|||
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<string>();
|
||||
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<string>();
|
||||
var expected = new InvalidOperationException("begin");
|
||||
var phases = new RecordingPhases(calls)
|
||||
{
|
||||
FailurePoint = "gpu-begin",
|
||||
Failure = expected,
|
||||
};
|
||||
|
||||
InvalidOperationException actual = Assert.Throws<InvalidOperationException>(
|
||||
() => 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<string>();
|
||||
var expected = new InvalidOperationException(failurePoint);
|
||||
var phases = new RecordingPhases(calls)
|
||||
{
|
||||
FailurePoint = failurePoint,
|
||||
Failure = expected,
|
||||
};
|
||||
|
||||
InvalidOperationException actual = Assert.Throws<InvalidOperationException>(
|
||||
() => 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<string>();
|
||||
var expected = new InvalidOperationException("close");
|
||||
var phases = new RecordingPhases(calls)
|
||||
{
|
||||
CloseFailure = expected,
|
||||
};
|
||||
|
||||
InvalidOperationException actual = Assert.Throws<InvalidOperationException>(
|
||||
() => 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<string>();
|
||||
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<AggregateException>(
|
||||
() => 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 Constructor_RejectsEveryMissingRequiredOwner()
|
||||
{
|
||||
var phases = new RecordingPhases([]);
|
||||
|
||||
Assert.Throws<ArgumentNullException>(() => new RenderFrameOrchestrator(
|
||||
null!, phases, phases, phases, phases));
|
||||
Assert.Throws<ArgumentNullException>(() => new RenderFrameOrchestrator(
|
||||
phases, null!, phases, phases, phases));
|
||||
Assert.Throws<ArgumentNullException>(() => new RenderFrameOrchestrator(
|
||||
phases, phases, null!, phases, phases));
|
||||
Assert.Throws<ArgumentNullException>(() => new RenderFrameOrchestrator(
|
||||
phases, phases, phases, null!, phases));
|
||||
Assert.Throws<ArgumentNullException>(() => new RenderFrameOrchestrator(
|
||||
phases, phases, phases, phases, null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Orchestrator_UsesOnlyTheExplicitTypedOwnerGraph()
|
||||
{
|
||||
Type[] expectedFieldTypes =
|
||||
[
|
||||
typeof(IRenderFrameLifetime),
|
||||
typeof(IRenderFrameResourcePhase),
|
||||
typeof(IWorldSceneFramePhase),
|
||||
typeof(IPrivatePresentationFramePhase),
|
||||
typeof(IRenderFrameDiagnosticsPhase),
|
||||
];
|
||||
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", "gpu-end"],
|
||||
"world" => ["gpu-begin", "resources", "world", "gpu-end"],
|
||||
"presentation" =>
|
||||
["gpu-begin", "resources", "world", "presentation", "gpu-end"],
|
||||
"diagnostics" =>
|
||||
[
|
||||
"gpu-begin", "resources", "world", "presentation", "diagnostics",
|
||||
"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);
|
||||
|
||||
private sealed class RecordingPhases :
|
||||
IRenderFrameLifetime,
|
||||
IRenderFrameResourcePhase,
|
||||
IWorldSceneFramePhase,
|
||||
IPrivatePresentationFramePhase,
|
||||
IRenderFrameDiagnosticsPhase
|
||||
{
|
||||
private readonly List<string> _calls;
|
||||
|
||||
public RecordingPhases(List<string> calls)
|
||||
{
|
||||
_calls = calls;
|
||||
}
|
||||
|
||||
public string? FailurePoint { get; init; }
|
||||
public Exception? Failure { get; init; }
|
||||
public Exception? CloseFailure { 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 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue