refactor(app): define ordered composition contract

This commit is contained in:
Erik 2026-07-22 15:03:40 +02:00
parent 1d04b94da2
commit adb204560d
6 changed files with 927 additions and 0 deletions

View file

@ -0,0 +1,114 @@
using AcDream.App.Composition;
namespace AcDream.App.Tests.Composition;
public sealed class CompositionAcquisitionScopeTests
{
[Fact]
public void RollbackReleasesOnlyUnpublishedResourcesInReverseOrder()
{
var calls = new List<string>();
var scope = new CompositionAcquisitionScope();
var first = scope.Own("first", new Resource("first"), value => calls.Add(value.Name));
var published = scope.Own("published", new Resource("published"), value => calls.Add(value.Name));
scope.Own("last", new Resource("last"), value => calls.Add(value.Name));
published.Transfer();
InvalidOperationException original = Assert.Throws<InvalidOperationException>(() =>
scope.RollbackAndThrow(new InvalidOperationException("construction failed")));
Assert.Equal("construction failed", original.Message);
Assert.Equal(["last", "first"], calls);
Assert.True(scope.IsCleanupComplete);
Assert.Equal("first", first.Resource.Name);
}
[Fact]
public void CleanupFailureRetainsOriginalAndRetriesOnlyUnfinishedAction()
{
var calls = new List<string>();
int middleFailures = 1;
var scope = new CompositionAcquisitionScope();
scope.Own("first", new Resource("first"), value => calls.Add(value.Name));
scope.Own("middle", new Resource("middle"), value =>
{
calls.Add(value.Name);
if (middleFailures-- > 0)
throw new InvalidOperationException("release failed");
});
scope.Own("last", new Resource("last"), value => calls.Add(value.Name));
CompositionAcquisitionException failure =
Assert.Throws<CompositionAcquisitionException>(() =>
scope.RollbackAndThrow(new InvalidOperationException("construction failed")));
Assert.False(failure.IsCleanupComplete);
Assert.Contains(
failure.InnerExceptions,
error => error.Message == "construction failed");
Assert.Equal(["last", "middle", "first"], calls);
failure.RetryCleanup();
failure.RetryCleanup();
Assert.True(failure.IsCleanupComplete);
Assert.Equal(["last", "middle", "first", "middle"], calls);
}
[Fact]
public void PublicationTransfersOnlyAfterPublisherReturns()
{
int releases = 0;
var failing = new CompositionAcquisitionScope();
var lease = failing.Own(
"resource",
new Resource("resource"),
_ => releases++);
Assert.Throws<InvalidOperationException>(() =>
lease.Publish(_ => throw new InvalidOperationException("publish failed")));
Assert.Throws<InvalidOperationException>(() =>
failing.RollbackAndThrow(new InvalidOperationException("phase failed")));
Assert.Equal(1, releases);
var successful = new CompositionAcquisitionScope();
var transferred = successful.Own(
"resource",
new Resource("resource"),
_ => releases++);
transferred.Publish(_ => { });
successful.Complete();
Assert.Equal(1, releases);
Assert.Throws<InvalidOperationException>(transferred.Transfer);
}
[Fact]
public void CompleteRejectsAnUnpublishedAcquisition()
{
var scope = new CompositionAcquisitionScope();
scope.Own("pending", new Resource("pending"), _ => { });
InvalidOperationException failure = Assert.Throws<InvalidOperationException>(scope.Complete);
Assert.Contains("pending", failure.Message, StringComparison.Ordinal);
}
[Fact]
public void ThrowingFactoryNeverCreatesCleanupOwnership()
{
var scope = new CompositionAcquisitionScope();
int releases = 0;
Assert.Throws<InvalidOperationException>(() =>
scope.Acquire<Resource>(
"resource",
() => throw new InvalidOperationException("factory failed"),
_ => releases++));
Assert.True(scope.IsCleanupComplete);
Assert.Equal(0, releases);
}
private sealed record Resource(string Name);
}

View file

@ -0,0 +1,189 @@
using AcDream.App.Composition;
namespace AcDream.App.Tests.Composition;
public sealed class GameWindowCompositionPipelineTests
{
[Theory]
[InlineData(1)]
[InlineData(2)]
[InlineData(3)]
[InlineData(4)]
[InlineData(5)]
[InlineData(6)]
[InlineData(7)]
[InlineData(8)]
[InlineData(9)]
public void FailureRunsExactlyTheRequiredPrefix(int failingPhase)
{
var phases = new RecordingPhases(failingPhase);
GameWindowCompositionPipeline<Token, Token, Token, Token, Token, Token, Token, Token, Token>
pipeline = phases.BuildPipeline();
Assert.Throws<InvalidOperationException>(() => pipeline.Run(phases.Platform));
Assert.Equal(
Enumerable.Range(1, failingPhase).Select(value => $"phase-{value}"),
phases.Calls);
}
[Fact]
public void SuccessPassesExactResultsAndStartsSessionLast()
{
var phases = new RecordingPhases(failingPhase: 0);
GameWindowCompositionPipeline<Token, Token, Token, Token, Token, Token, Token, Token, Token>
pipeline = phases.BuildPipeline();
pipeline.Run(phases.Platform);
Assert.Equal(
Enumerable.Range(1, 9).Select(value => $"phase-{value}"),
phases.Calls);
Assert.Equal("phase-9", phases.Calls[^1]);
}
private sealed record Token(int Phase);
private sealed class RecordingPhases(int failingPhase) :
IHostInputCameraCompositionPhase<Token, Token>,
IContentEffectsAudioCompositionPhase<Token, Token, Token>,
ISettingsDevToolsCompositionPhase<Token, Token, Token, Token>,
IWorldRenderCompositionPhase<Token, Token, Token, Token>,
IInteractionUiCompositionPhase<Token, Token, Token, Token, Token>,
ILivePresentationCompositionPhase<Token, Token, Token, Token, Token>,
ISessionPlayerCompositionPhase<Token, Token, Token, Token, Token, Token>,
IFrameRootCompositionPhase<Token, Token, Token, Token, Token, Token, Token>,
ISessionStartCompositionPhase<Token>
{
public Token Platform { get; } = new(0);
public List<string> Calls { get; } = [];
private readonly Token _host = new(1);
private readonly Token _content = new(2);
private readonly Token _settings = new(3);
private readonly Token _world = new(4);
private readonly Token _interaction = new(5);
private readonly Token _live = new(6);
private readonly Token _session = new(7);
private readonly Token _frame = new(8);
public GameWindowCompositionPipeline<Token, Token, Token, Token, Token, Token, Token, Token, Token>
BuildPipeline() => new(this, this, this, this, this, this, this, this, this);
Token IHostInputCameraCompositionPhase<Token, Token>.Compose(Token platform)
{
Assert.Same(Platform, platform);
Record(1);
return _host;
}
Token IContentEffectsAudioCompositionPhase<Token, Token, Token>.Compose(
Token platform,
Token host)
{
Assert.Same(Platform, platform);
Assert.Same(_host, host);
Record(2);
return _content;
}
Token ISettingsDevToolsCompositionPhase<Token, Token, Token, Token>.Compose(
Token platform,
Token host,
Token content)
{
Assert.Same(Platform, platform);
Assert.Same(_host, host);
Assert.Same(_content, content);
Record(3);
return _settings;
}
Token IWorldRenderCompositionPhase<Token, Token, Token, Token>.Compose(
Token platform,
Token content,
Token settings)
{
Assert.Same(Platform, platform);
Assert.Same(_content, content);
Assert.Same(_settings, settings);
Record(4);
return _world;
}
Token IInteractionUiCompositionPhase<Token, Token, Token, Token, Token>.Compose(
Token host,
Token content,
Token settings,
Token world)
{
Assert.Same(_host, host);
Assert.Same(_content, content);
Assert.Same(_settings, settings);
Assert.Same(_world, world);
Record(5);
return _interaction;
}
Token ILivePresentationCompositionPhase<Token, Token, Token, Token, Token>.Compose(
Token host,
Token content,
Token world,
Token interaction)
{
Assert.Same(_host, host);
Assert.Same(_content, content);
Assert.Same(_world, world);
Assert.Same(_interaction, interaction);
Record(6);
return _live;
}
Token ISessionPlayerCompositionPhase<Token, Token, Token, Token, Token, Token>.Compose(
Token host,
Token content,
Token world,
Token interaction,
Token live)
{
Assert.Same(_host, host);
Assert.Same(_content, content);
Assert.Same(_world, world);
Assert.Same(_interaction, interaction);
Assert.Same(_live, live);
Record(7);
return _session;
}
Token IFrameRootCompositionPhase<Token, Token, Token, Token, Token, Token, Token>.Compose(
Token host,
Token settings,
Token world,
Token interaction,
Token live,
Token session)
{
Assert.Same(_host, host);
Assert.Same(_settings, settings);
Assert.Same(_world, world);
Assert.Same(_interaction, interaction);
Assert.Same(_live, live);
Assert.Same(_session, session);
Record(8);
return _frame;
}
void ISessionStartCompositionPhase<Token>.Start(Token frame)
{
Assert.Same(_frame, frame);
Record(9);
}
private void Record(int phase)
{
Calls.Add($"phase-{phase}");
if (failingPhase == phase)
throw new InvalidOperationException($"phase {phase} failed");
}
}
}

View file

@ -0,0 +1,191 @@
using AcDream.App.Composition;
namespace AcDream.App.Tests.Composition;
public sealed class GameWindowPlatformAcquisitionTests
{
[Fact]
public void AcquirePublishesGraphicsBeforeInputConstruction()
{
var trace = new List<string>();
var owner = new Publication(trace);
var graphics = new Resource("graphics", trace);
var input = new Resource("input", trace);
GameWindowPlatformResult<Resource, Resource> result =
GameWindowPlatformAcquisition.Acquire(
() =>
{
trace.Add("gl.factory");
return graphics;
},
value => value.ReleaseUnpublished(),
() =>
{
Assert.Same(graphics, owner.Graphics);
trace.Add("input.factory");
return input;
},
value => value.ReleaseUnpublished(),
owner);
Assert.Same(graphics, result.Graphics);
Assert.Same(input, result.Input);
Assert.Equal(
["gl.factory", "gl.publish", "input.factory", "input.publish"],
trace);
owner.Dispose();
owner.Dispose();
Assert.Equal(1, graphics.OwnerReleaseCalls);
Assert.Equal(1, input.OwnerReleaseCalls);
Assert.Equal(0, graphics.UnpublishedReleaseCalls);
Assert.Equal(0, input.UnpublishedReleaseCalls);
Assert.Equal("input.owner-release", trace[^2]);
Assert.Equal("graphics.owner-release", trace[^1]);
}
[Fact]
public void GraphicsFactoryFailureNeverConstructsInput()
{
var trace = new List<string>();
var owner = new Publication(trace);
Assert.Throws<InvalidOperationException>(() =>
GameWindowPlatformAcquisition.Acquire<Resource, Resource>(
() => throw new InvalidOperationException("graphics failed"),
value => value.ReleaseUnpublished(),
() =>
{
trace.Add("input.factory");
return new Resource("input", trace);
},
value => value.ReleaseUnpublished(),
owner));
Assert.Empty(trace);
Assert.Null(owner.Graphics);
Assert.Null(owner.Input);
}
[Fact]
public void InputFactoryFailureLeavesPublishedGraphicsLifetimeOwned()
{
var trace = new List<string>();
var owner = new Publication(trace);
var graphics = new Resource("graphics", trace);
Assert.Throws<InvalidOperationException>(() =>
GameWindowPlatformAcquisition.Acquire<Resource, Resource>(
() =>
{
trace.Add("gl.factory");
return graphics;
},
value => value.ReleaseUnpublished(),
() =>
{
Assert.Same(graphics, owner.Graphics);
trace.Add("input.factory");
throw new InvalidOperationException("input failed");
},
value => value.ReleaseUnpublished(),
owner));
Assert.Equal(["gl.factory", "gl.publish", "input.factory"], trace);
Assert.Equal(0, graphics.UnpublishedReleaseCalls);
owner.Dispose();
Assert.Equal(1, graphics.OwnerReleaseCalls);
}
[Theory]
[InlineData((int)GameWindowPlatformAcquisitionPoint.GraphicsPublished)]
[InlineData((int)GameWindowPlatformAcquisitionPoint.InputPublished)]
public void FailureImmediatelyAfterPublicationNeverRollsBackPublishedOwner(
int failurePointValue)
{
var failurePoint = (GameWindowPlatformAcquisitionPoint)failurePointValue;
var trace = new List<string>();
var owner = new Publication(trace);
var graphics = new Resource("graphics", trace);
var input = new Resource("input", trace);
Assert.Throws<InvalidOperationException>(() =>
GameWindowPlatformAcquisition.Acquire(
() => graphics,
value => value.ReleaseUnpublished(),
() => input,
value => value.ReleaseUnpublished(),
owner,
point =>
{
if (point == failurePoint)
throw new InvalidOperationException("injected failure");
}));
Assert.Equal(0, graphics.UnpublishedReleaseCalls);
Assert.Equal(0, input.UnpublishedReleaseCalls);
Assert.NotNull(owner.Graphics);
Assert.Equal(
failurePoint == GameWindowPlatformAcquisitionPoint.InputPublished,
owner.Input is not null);
owner.Dispose();
Assert.Equal(1, graphics.OwnerReleaseCalls);
Assert.Equal(
failurePoint == GameWindowPlatformAcquisitionPoint.InputPublished ? 1 : 0,
input.OwnerReleaseCalls);
}
private sealed class Publication(List<string> trace) :
IGameWindowPlatformPublication<Resource, Resource>,
IDisposable
{
public Resource? Graphics { get; private set; }
public Resource? Input { get; private set; }
public void PublishGraphics(Resource graphics)
{
if (Graphics is not null)
throw new InvalidOperationException("graphics already published");
Graphics = graphics;
trace.Add("gl.publish");
}
public void PublishInput(Resource input)
{
if (Input is not null)
throw new InvalidOperationException("input already published");
Input = input;
trace.Add("input.publish");
}
public void Dispose()
{
Resource? input = Input;
Input = null;
input?.ReleaseFromOwner();
Resource? graphics = Graphics;
Graphics = null;
graphics?.ReleaseFromOwner();
}
}
private sealed class Resource(string name, List<string> trace)
{
public int UnpublishedReleaseCalls { get; private set; }
public int OwnerReleaseCalls { get; private set; }
public void ReleaseUnpublished()
{
UnpublishedReleaseCalls++;
trace.Add($"{name}.unpublished-release");
}
public void ReleaseFromOwner()
{
OwnerReleaseCalls++;
trace.Add($"{name}.owner-release");
}
}
}