refactor(app): define ordered composition contract
This commit is contained in:
parent
1d04b94da2
commit
adb204560d
6 changed files with 927 additions and 0 deletions
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue