acdream/tests/AcDream.App.Tests/Rendering/RenderFrameOrchestratorTests.cs
Erik c673f767e9 feat(rendering): port retail building degrade walk
Add exact retail building degrade selection, shared FPS/degrade ownership, complete-body gating, selected-shell submission, ordinary ladder mesh residency, frame-scoped retry rearm, Config controls, installed-DAT census, and lifecycle/allocation proofs.

Reviews: OpenAI retail pass 3 PASS; OpenAI production pass 5 PASS. Gates: Release 0W/0E; focused 285/285; hermetic 16811/16811; InstalledDat 469 pass, 10 documented fail, 1 documented skip; both manifests 30/30.
2026-09-04 22:29:18 +02:00

547 lines
20 KiB
C#

using System.Reflection;
using AcDream.App.Rendering;
using AcDream.UI.Abstractions.Panels.Settings;
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", "measure-begin", "resources", "world", "presentation",
"measure-end",
"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);
}
[Theory]
[InlineData(0, 1080)]
[InlineData(1920, 0)]
[InlineData(0, 0)]
[InlineData(-1, 1080)]
public void ZeroAreaViewport_SkipsTheFrameBeforeAnyGpuWork(int width, int height)
{
// Campaign VM VM7 owner gate (2026-08-23): alt-tabbing out of
// exclusive fullscreen auto-iconifies the window; for one frame the
// size reads 0x0 while the swapchain is still current, and the
// zero-area frame crashed the client in
// RenderPackActivationExtent.Validate. The rule lives here, before
// BeginFrame, so no phase, measurement, diagnostics or recovery can
// observe a zero extent, and the outcome says so for the host.
var calls = new List<string>();
var phases = new RecordingPhases(calls);
RenderFrameOutcome outcome = Create(phases).Render(
Input with { ViewportWidth = width, ViewportHeight = height });
Assert.True(outcome.SkippedZeroArea);
Assert.Empty(calls);
Assert.Empty(phases.ObservedInputs);
}
[Fact]
public void AcceptedRenderTicksSharedDegradeOwnerExactlyOnceAndZeroAreaDoesNot()
{
var calls = new List<string>();
var phases = new RecordingPhases(calls);
var degradation = new RecordingDegradeTick(calls);
var orchestrator = new RenderFrameOrchestrator(
phases, phases, phases, phases, phases, phases, phases, phases,
degradation);
double[] durations =
[
1d / 1024d, 2d / 1024d, 3d / 1024d, 4d / 1024d,
5d / 1024d, 6d / 1024d, 7d / 1024d, 8d / 1024d,
9d / 1024d, 10d / 1024d, 11d / 1024d, 12d / 1024d,
13d / 1024d, 14d / 1024d, 15d / 1024d, 16d / 1024d,
17d / 1024d, 18d / 1024d, 19d / 1024d, 20d / 1024d,
31d / 1024d,
];
for (int i = 0; i < durations.Length - 1; i++)
orchestrator.Render(Input with { DeltaSeconds = durations[i] });
orchestrator.Render(Input with { ViewportWidth = 0 });
orchestrator.Render(Input with { DeltaSeconds = durations[^1] });
Assert.Equal(durations, degradation.Durations);
Assert.Equal(0x42C30C31u, BitConverter.SingleToUInt32Bits(degradation.Fps));
Assert.Equal(durations.Length, calls.Count(static call => call == "degrade-tick"));
for (int i = 0; i < calls.Count; i++)
{
if (calls[i] == "gpu-begin")
{
Assert.True(i > 0, "degrade tick must precede BeginFrame");
Assert.Equal("degrade-tick", calls[i - 1]);
}
}
}
[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", "measure-begin", "resources", "world", "presentation",
"measure-end",
"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", "measure-begin", "resources", "world",
"measure-end", "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", "measure-begin", "resources", "world", "presentation",
"measure-end",
"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, phases));
Assert.Throws<ArgumentNullException>(() => new RenderFrameOrchestrator(
phases, null!, phases, phases, phases, phases, phases, phases));
Assert.Throws<ArgumentNullException>(() => new RenderFrameOrchestrator(
phases, phases, null!, phases, phases, phases, phases, phases));
Assert.Throws<ArgumentNullException>(() => new RenderFrameOrchestrator(
phases, phases, phases, null!, phases, phases, phases, phases));
Assert.Throws<ArgumentNullException>(() => new RenderFrameOrchestrator(
phases, phases, phases, phases, null!, phases, phases, phases));
Assert.Throws<ArgumentNullException>(() => new RenderFrameOrchestrator(
phases, phases, phases, phases, phases, null!, phases, phases));
Assert.Throws<ArgumentNullException>(() => new RenderFrameOrchestrator(
phases, phases, phases, phases, phases, phases, null!, phases));
Assert.Throws<ArgumentNullException>(() => new RenderFrameOrchestrator(
phases, phases, phases, phases, phases, phases, phases, null!));
}
[Fact]
public void Orchestrator_UsesOnlyTheExplicitTypedOwnerGraph()
{
Type[] expectedFieldTypes =
[
typeof(IRenderFrameLifetime),
typeof(IRenderFrameGpuMeasurement),
typeof(IRenderFrameResourcePhase),
typeof(IWorldSceneFramePhase),
typeof(IPrivatePresentationFramePhase),
typeof(IRenderFrameDiagnosticsPhase),
typeof(IRenderFramePostDiagnosticsPhase),
typeof(IRenderFrameFailureRecovery),
typeof(IBuildingDegradeFrameTick),
];
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.Where(type => type.IsInterface))
{
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", "measure-begin", "resources", "measure-end",
"recovery-abort", "gpu-end",
],
"world" =>
[
"gpu-begin", "measure-begin", "resources", "world", "measure-end",
"recovery-abort", "gpu-end",
],
"presentation" =>
[
"gpu-begin", "measure-begin", "resources", "world", "presentation",
"measure-end",
"recovery-abort", "gpu-end",
],
"diagnostics" =>
[
"gpu-begin", "measure-begin", "resources", "world", "presentation",
"measure-end", "diagnostics",
"recovery-abort", "gpu-end",
],
"post-diagnostics" =>
[
"gpu-begin", "measure-begin", "resources", "world", "presentation",
"measure-end", "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, phases);
private sealed class RecordingDegradeTick : IBuildingDegradeFrameTick
{
private readonly List<string> _calls;
private readonly BuildingDegradeController _inner = new(
() => DisplaySettings.Default);
public RecordingDegradeTick(List<string> calls) => _calls = calls;
public List<double> Durations { get; } = [];
public float Fps => _inner.Fps;
public void Tick(double elapsedSeconds)
{
_calls.Add("degrade-tick");
Durations.Add(elapsedSeconds);
_inner.Tick(elapsedSeconds);
}
}
private sealed class RecordingPhases :
IRenderFrameLifetime,
IRenderFrameGpuMeasurement,
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; }
void IRenderFrameLifetime.BeginFrame() => Record("gpu-begin");
void IRenderFrameLifetime.EndFrame()
{
_calls.Add("gpu-end");
if (CloseFailure is not null)
throw CloseFailure;
}
void IRenderFrameGpuMeasurement.BeginFrame() => Record("measure-begin");
void IRenderFrameGpuMeasurement.EndFrame() => _calls.Add("measure-end");
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;
}
}
}