Make scripted lifecycle checkpoints acknowledged post-diagnostics render barriers, capture the exact frame outcome beside canonical resource ownership, and harden the nine-stop route with ordered same-location cache and lifetime gates without weakening process residency thresholds. Co-authored-by: Codex <codex@openai.com>
439 lines
16 KiB
C#
439 lines
16 KiB
C#
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", "post-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),
|
|
("post-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")]
|
|
[InlineData("post-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", "post-diagnostics", "gpu-end",
|
|
],
|
|
calls);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("resources")]
|
|
[InlineData("world")]
|
|
[InlineData("presentation")]
|
|
[InlineData("diagnostics")]
|
|
[InlineData("post-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 RenderAndRecoveryFailure_AreAggregatedBeforeGpuClose()
|
|
{
|
|
var calls = new List<string>();
|
|
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<AggregateException>(
|
|
() => 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<string>();
|
|
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<AggregateException>(
|
|
() => 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<ArgumentNullException>(() => new RenderFrameOrchestrator(
|
|
null!, phases, phases, phases, phases, phases, phases));
|
|
Assert.Throws<ArgumentNullException>(() => new RenderFrameOrchestrator(
|
|
phases, null!, phases, phases, phases, phases, phases));
|
|
Assert.Throws<ArgumentNullException>(() => new RenderFrameOrchestrator(
|
|
phases, phases, null!, phases, phases, phases, phases));
|
|
Assert.Throws<ArgumentNullException>(() => new RenderFrameOrchestrator(
|
|
phases, phases, phases, null!, phases, phases, phases));
|
|
Assert.Throws<ArgumentNullException>(() => new RenderFrameOrchestrator(
|
|
phases, phases, phases, phases, null!, phases, phases));
|
|
Assert.Throws<ArgumentNullException>(() => new RenderFrameOrchestrator(
|
|
phases, phases, phases, phases, phases, null!, phases));
|
|
Assert.Throws<ArgumentNullException>(() => new RenderFrameOrchestrator(
|
|
phases, phases, phases, phases, phases, phases, null!));
|
|
}
|
|
|
|
[Fact]
|
|
public void Orchestrator_UsesOnlyTheExplicitTypedOwnerGraph()
|
|
{
|
|
Type[] expectedFieldTypes =
|
|
[
|
|
typeof(IRenderFrameLifetime),
|
|
typeof(IRenderFrameResourcePhase),
|
|
typeof(IWorldSceneFramePhase),
|
|
typeof(IPrivatePresentationFramePhase),
|
|
typeof(IRenderFrameDiagnosticsPhase),
|
|
typeof(IRenderFramePostDiagnosticsPhase),
|
|
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",
|
|
],
|
|
"post-diagnostics" =>
|
|
[
|
|
"gpu-begin", "resources", "world", "presentation", "diagnostics",
|
|
"post-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"],
|
|
"post-diagnostics" =>
|
|
[
|
|
"resources", "world", "presentation", "diagnostics",
|
|
"post-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, phases);
|
|
|
|
private sealed class RecordingPhases :
|
|
IRenderFrameLifetime,
|
|
IRenderFrameResourcePhase,
|
|
IWorldSceneFramePhase,
|
|
IPrivatePresentationFramePhase,
|
|
IRenderFrameDiagnosticsPhase,
|
|
IRenderFramePostDiagnosticsPhase,
|
|
IRenderFrameFailureRecovery
|
|
{
|
|
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 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");
|
|
}
|
|
|
|
public void Process(RenderFrameInput input, RenderFrameOutcome outcome)
|
|
{
|
|
ObservedInputs.Add(("post-diagnostics", input));
|
|
ObservedOutcome = outcome;
|
|
Record("post-diagnostics");
|
|
}
|
|
|
|
private void Record(string call)
|
|
{
|
|
_calls.Add(call);
|
|
if (FailurePoint == call && Failure is not null)
|
|
throw Failure;
|
|
}
|
|
}
|
|
}
|