refactor(app): define ordered composition contract
This commit is contained in:
parent
1d04b94da2
commit
adb204560d
6 changed files with 927 additions and 0 deletions
242
src/AcDream.App/Composition/CompositionAcquisitionScope.cs
Normal file
242
src/AcDream.App/Composition/CompositionAcquisitionScope.cs
Normal file
|
|
@ -0,0 +1,242 @@
|
||||||
|
using System.Runtime.ExceptionServices;
|
||||||
|
using AcDream.App.Rendering;
|
||||||
|
|
||||||
|
namespace AcDream.App.Composition;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Owns resources acquired inside one startup-composition phase until each
|
||||||
|
/// resource is published to its long-lived owner. Unpublished resources roll
|
||||||
|
/// back in reverse dependency order; successful cleanup never replays.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class CompositionAcquisitionScope : IRetryableResourceCleanup
|
||||||
|
{
|
||||||
|
internal enum EntryState
|
||||||
|
{
|
||||||
|
Owned,
|
||||||
|
Transferred,
|
||||||
|
Released,
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class Entry(string name, object resource, Action release)
|
||||||
|
{
|
||||||
|
public string Name { get; } = name;
|
||||||
|
public object Resource { get; } = resource;
|
||||||
|
public Action Release { get; } = release;
|
||||||
|
public EntryState State { get; set; } = EntryState.Owned;
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly List<Entry> _entries = [];
|
||||||
|
private bool _cleanupActive;
|
||||||
|
private bool _closed;
|
||||||
|
|
||||||
|
public bool IsCleanupComplete =>
|
||||||
|
_entries.All(static entry => entry.State is not EntryState.Owned);
|
||||||
|
|
||||||
|
public CompositionAcquisitionLease<T> Acquire<T>(
|
||||||
|
string name,
|
||||||
|
Func<T> factory,
|
||||||
|
Action<T> release)
|
||||||
|
where T : class
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(name);
|
||||||
|
ArgumentNullException.ThrowIfNull(factory);
|
||||||
|
ArgumentNullException.ThrowIfNull(release);
|
||||||
|
EnsureAcceptingOwnership();
|
||||||
|
|
||||||
|
T resource = factory()
|
||||||
|
?? throw new InvalidOperationException(
|
||||||
|
$"Composition factory '{name}' returned null.");
|
||||||
|
return Own(name, resource, release);
|
||||||
|
}
|
||||||
|
|
||||||
|
public CompositionAcquisitionLease<T> Own<T>(
|
||||||
|
string name,
|
||||||
|
T resource,
|
||||||
|
Action<T> release)
|
||||||
|
where T : class
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(name);
|
||||||
|
ArgumentNullException.ThrowIfNull(resource);
|
||||||
|
ArgumentNullException.ThrowIfNull(release);
|
||||||
|
EnsureAcceptingOwnership();
|
||||||
|
|
||||||
|
var entry = new Entry(name, resource, () => release(resource));
|
||||||
|
_entries.Add(entry);
|
||||||
|
return new CompositionAcquisitionLease<T>(
|
||||||
|
this,
|
||||||
|
entry,
|
||||||
|
resource);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Closes a successful phase. Every acquisition must already have moved to
|
||||||
|
/// a typed long-lived owner or aggregate owner.
|
||||||
|
/// </summary>
|
||||||
|
public void Complete()
|
||||||
|
{
|
||||||
|
if (_cleanupActive)
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"Composition cleanup is currently active.");
|
||||||
|
if (_closed)
|
||||||
|
return;
|
||||||
|
if (!IsCleanupComplete)
|
||||||
|
{
|
||||||
|
string pending = string.Join(
|
||||||
|
", ",
|
||||||
|
_entries
|
||||||
|
.Where(static entry => entry.State == EntryState.Owned)
|
||||||
|
.Select(static entry => entry.Name));
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Composition phase completed with unpublished resources: {pending}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
_closed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Rolls back the unpublished prefix and then rethrows the construction
|
||||||
|
/// failure. If cleanup itself fails, the returned exception retains this
|
||||||
|
/// exact scope as retry ownership.
|
||||||
|
/// </summary>
|
||||||
|
public void RollbackAndThrow(Exception constructionFailure)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(constructionFailure);
|
||||||
|
_closed = true;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
RetryCleanup();
|
||||||
|
}
|
||||||
|
catch (AggregateException cleanupFailure)
|
||||||
|
{
|
||||||
|
var failures = new List<Exception> { constructionFailure };
|
||||||
|
failures.AddRange(cleanupFailure.InnerExceptions);
|
||||||
|
throw new CompositionAcquisitionException(
|
||||||
|
"Startup composition failed and rollback remains incomplete.",
|
||||||
|
this,
|
||||||
|
failures);
|
||||||
|
}
|
||||||
|
|
||||||
|
ExceptionDispatchInfo.Capture(constructionFailure).Throw();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RetryCleanup()
|
||||||
|
{
|
||||||
|
if (_cleanupActive || IsCleanupComplete)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_closed = true;
|
||||||
|
_cleanupActive = true;
|
||||||
|
List<Exception>? failures = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
for (int i = _entries.Count - 1; i >= 0; i--)
|
||||||
|
{
|
||||||
|
Entry entry = _entries[i];
|
||||||
|
if (entry.State != EntryState.Owned)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
entry.Release();
|
||||||
|
entry.State = EntryState.Released;
|
||||||
|
}
|
||||||
|
catch (Exception failure)
|
||||||
|
{
|
||||||
|
(failures ??= []).Add(new InvalidOperationException(
|
||||||
|
$"Composition cleanup operation '{entry.Name}' failed.",
|
||||||
|
failure));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_cleanupActive = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failures is not null)
|
||||||
|
{
|
||||||
|
throw new AggregateException(
|
||||||
|
"Startup composition rollback remains incomplete.",
|
||||||
|
failures);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EnsureAcceptingOwnership()
|
||||||
|
{
|
||||||
|
if (_closed || _cleanupActive)
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"The composition acquisition scope is no longer accepting ownership.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Transfer<T>(Entry entry, T expected)
|
||||||
|
where T : class
|
||||||
|
{
|
||||||
|
if (_closed || _cleanupActive)
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"The composition acquisition scope is no longer transferable.");
|
||||||
|
if (!ReferenceEquals(entry.Resource, expected))
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"The acquisition lease does not own the expected resource.");
|
||||||
|
if (entry.State != EntryState.Owned)
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Composition resource '{entry.Name}' has already been transferred.");
|
||||||
|
|
||||||
|
entry.State = EntryState.Transferred;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class CompositionAcquisitionLease<T>
|
||||||
|
where T : class
|
||||||
|
{
|
||||||
|
private readonly CompositionAcquisitionScope _scope;
|
||||||
|
private readonly Entry _entry;
|
||||||
|
|
||||||
|
internal CompositionAcquisitionLease(
|
||||||
|
CompositionAcquisitionScope scope,
|
||||||
|
Entry entry,
|
||||||
|
T resource)
|
||||||
|
{
|
||||||
|
_scope = scope;
|
||||||
|
_entry = entry;
|
||||||
|
Resource = resource;
|
||||||
|
}
|
||||||
|
|
||||||
|
public T Resource { get; }
|
||||||
|
|
||||||
|
public T Transfer()
|
||||||
|
{
|
||||||
|
_scope.Transfer(_entry, Resource);
|
||||||
|
return Resource;
|
||||||
|
}
|
||||||
|
|
||||||
|
public T Publish(Action<T> publish)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(publish);
|
||||||
|
publish(Resource);
|
||||||
|
return Transfer();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Construction failure whose incomplete rollback remains retryable by the
|
||||||
|
/// process lifetime cleanup ledger.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class CompositionAcquisitionException : AggregateException,
|
||||||
|
IRetryableResourceCleanup
|
||||||
|
{
|
||||||
|
private readonly IRetryableResourceCleanup _cleanup;
|
||||||
|
|
||||||
|
public CompositionAcquisitionException(
|
||||||
|
string message,
|
||||||
|
IRetryableResourceCleanup cleanup,
|
||||||
|
IEnumerable<Exception> failures)
|
||||||
|
: base(message, failures)
|
||||||
|
{
|
||||||
|
_cleanup = cleanup ?? throw new ArgumentNullException(nameof(cleanup));
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsCleanupComplete => _cleanup.IsCleanupComplete;
|
||||||
|
|
||||||
|
public void RetryCleanup() => _cleanup.RetryCleanup();
|
||||||
|
}
|
||||||
119
src/AcDream.App/Composition/GameWindowCompositionPipeline.cs
Normal file
119
src/AcDream.App/Composition/GameWindowCompositionPipeline.cs
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
namespace AcDream.App.Composition;
|
||||||
|
|
||||||
|
internal interface IHostInputCameraCompositionPhase<in TPlatform, out TResult>
|
||||||
|
{
|
||||||
|
TResult Compose(TPlatform platform);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal interface IContentEffectsAudioCompositionPhase<in TPlatform, in THost, out TResult>
|
||||||
|
{
|
||||||
|
TResult Compose(TPlatform platform, THost host);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal interface ISettingsDevToolsCompositionPhase<in TPlatform, in THost, in TContent, out TResult>
|
||||||
|
{
|
||||||
|
TResult Compose(TPlatform platform, THost host, TContent content);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal interface IWorldRenderCompositionPhase<in TPlatform, in TContent, in TSettings, out TResult>
|
||||||
|
{
|
||||||
|
TResult Compose(TPlatform platform, TContent content, TSettings settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal interface IInteractionUiCompositionPhase<in THost, in TContent, in TSettings, in TWorld, out TResult>
|
||||||
|
{
|
||||||
|
TResult Compose(THost host, TContent content, TSettings settings, TWorld world);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal interface ILivePresentationCompositionPhase<in THost, in TContent, in TWorld, in TInteraction, out TResult>
|
||||||
|
{
|
||||||
|
TResult Compose(THost host, TContent content, TWorld world, TInteraction interaction);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal interface ISessionPlayerCompositionPhase<in THost, in TContent, in TWorld, in TInteraction, in TLive, out TResult>
|
||||||
|
{
|
||||||
|
TResult Compose(
|
||||||
|
THost host,
|
||||||
|
TContent content,
|
||||||
|
TWorld world,
|
||||||
|
TInteraction interaction,
|
||||||
|
TLive live);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal interface IFrameRootCompositionPhase<in THost, in TSettings, in TWorld, in TInteraction, in TLive, in TSession, out TResult>
|
||||||
|
{
|
||||||
|
TResult Compose(
|
||||||
|
THost host,
|
||||||
|
TSettings settings,
|
||||||
|
TWorld world,
|
||||||
|
TInteraction interaction,
|
||||||
|
TLive live,
|
||||||
|
TSession session);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal interface ISessionStartCompositionPhase<in TFrame>
|
||||||
|
{
|
||||||
|
void Start(TFrame frame);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fixed startup topology. It intentionally retains no cumulative context or
|
||||||
|
/// final runtime graph; immutable phase results exist only as locals during
|
||||||
|
/// one composition run.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class GameWindowCompositionPipeline<
|
||||||
|
TPlatform,
|
||||||
|
THost,
|
||||||
|
TContent,
|
||||||
|
TSettings,
|
||||||
|
TWorld,
|
||||||
|
TInteraction,
|
||||||
|
TLive,
|
||||||
|
TSession,
|
||||||
|
TFrame>
|
||||||
|
{
|
||||||
|
private readonly IHostInputCameraCompositionPhase<TPlatform, THost> _host;
|
||||||
|
private readonly IContentEffectsAudioCompositionPhase<TPlatform, THost, TContent> _content;
|
||||||
|
private readonly ISettingsDevToolsCompositionPhase<TPlatform, THost, TContent, TSettings> _settings;
|
||||||
|
private readonly IWorldRenderCompositionPhase<TPlatform, TContent, TSettings, TWorld> _world;
|
||||||
|
private readonly IInteractionUiCompositionPhase<THost, TContent, TSettings, TWorld, TInteraction> _interaction;
|
||||||
|
private readonly ILivePresentationCompositionPhase<THost, TContent, TWorld, TInteraction, TLive> _live;
|
||||||
|
private readonly ISessionPlayerCompositionPhase<THost, TContent, TWorld, TInteraction, TLive, TSession> _session;
|
||||||
|
private readonly IFrameRootCompositionPhase<THost, TSettings, TWorld, TInteraction, TLive, TSession, TFrame> _frame;
|
||||||
|
private readonly ISessionStartCompositionPhase<TFrame> _start;
|
||||||
|
|
||||||
|
public GameWindowCompositionPipeline(
|
||||||
|
IHostInputCameraCompositionPhase<TPlatform, THost> host,
|
||||||
|
IContentEffectsAudioCompositionPhase<TPlatform, THost, TContent> content,
|
||||||
|
ISettingsDevToolsCompositionPhase<TPlatform, THost, TContent, TSettings> settings,
|
||||||
|
IWorldRenderCompositionPhase<TPlatform, TContent, TSettings, TWorld> world,
|
||||||
|
IInteractionUiCompositionPhase<THost, TContent, TSettings, TWorld, TInteraction> interaction,
|
||||||
|
ILivePresentationCompositionPhase<THost, TContent, TWorld, TInteraction, TLive> live,
|
||||||
|
ISessionPlayerCompositionPhase<THost, TContent, TWorld, TInteraction, TLive, TSession> session,
|
||||||
|
IFrameRootCompositionPhase<THost, TSettings, TWorld, TInteraction, TLive, TSession, TFrame> frame,
|
||||||
|
ISessionStartCompositionPhase<TFrame> start)
|
||||||
|
{
|
||||||
|
_host = host ?? throw new ArgumentNullException(nameof(host));
|
||||||
|
_content = content ?? throw new ArgumentNullException(nameof(content));
|
||||||
|
_settings = settings ?? throw new ArgumentNullException(nameof(settings));
|
||||||
|
_world = world ?? throw new ArgumentNullException(nameof(world));
|
||||||
|
_interaction = interaction ?? throw new ArgumentNullException(nameof(interaction));
|
||||||
|
_live = live ?? throw new ArgumentNullException(nameof(live));
|
||||||
|
_session = session ?? throw new ArgumentNullException(nameof(session));
|
||||||
|
_frame = frame ?? throw new ArgumentNullException(nameof(frame));
|
||||||
|
_start = start ?? throw new ArgumentNullException(nameof(start));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Run(TPlatform platform)
|
||||||
|
{
|
||||||
|
THost host = _host.Compose(platform);
|
||||||
|
TContent content = _content.Compose(platform, host);
|
||||||
|
TSettings settings = _settings.Compose(platform, host, content);
|
||||||
|
TWorld world = _world.Compose(platform, content, settings);
|
||||||
|
TInteraction interaction = _interaction.Compose(host, content, settings, world);
|
||||||
|
TLive live = _live.Compose(host, content, world, interaction);
|
||||||
|
TSession session = _session.Compose(host, content, world, interaction, live);
|
||||||
|
TFrame frame = _frame.Compose(host, settings, world, interaction, live, session);
|
||||||
|
_start.Start(frame);
|
||||||
|
}
|
||||||
|
}
|
||||||
72
src/AcDream.App/Composition/GameWindowPlatformAcquisition.cs
Normal file
72
src/AcDream.App/Composition/GameWindowPlatformAcquisition.cs
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
namespace AcDream.App.Composition;
|
||||||
|
|
||||||
|
internal interface IGameWindowPlatformPublication<in TGraphics, in TInput>
|
||||||
|
where TGraphics : class
|
||||||
|
where TInput : class
|
||||||
|
{
|
||||||
|
void PublishGraphics(TGraphics graphics);
|
||||||
|
void PublishInput(TInput input);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed record GameWindowPlatformResult<TGraphics, TInput>(
|
||||||
|
TGraphics Graphics,
|
||||||
|
TInput Input)
|
||||||
|
where TGraphics : class
|
||||||
|
where TInput : class;
|
||||||
|
|
||||||
|
internal enum GameWindowPlatformAcquisitionPoint
|
||||||
|
{
|
||||||
|
GraphicsPublished,
|
||||||
|
InputPublished,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Exact host-platform acquisition prelude. Graphics ownership publishes
|
||||||
|
/// before input construction starts so a later input failure remains
|
||||||
|
/// recoverable through the normal lifetime transaction.
|
||||||
|
/// </summary>
|
||||||
|
internal static class GameWindowPlatformAcquisition
|
||||||
|
{
|
||||||
|
public static GameWindowPlatformResult<TGraphics, TInput> Acquire<TGraphics, TInput>(
|
||||||
|
Func<TGraphics> graphicsFactory,
|
||||||
|
Action<TGraphics> releaseUnpublishedGraphics,
|
||||||
|
Func<TInput> inputFactory,
|
||||||
|
Action<TInput> releaseUnpublishedInput,
|
||||||
|
IGameWindowPlatformPublication<TGraphics, TInput> publication,
|
||||||
|
Action<GameWindowPlatformAcquisitionPoint>? faultInjection = null)
|
||||||
|
where TGraphics : class
|
||||||
|
where TInput : class
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(graphicsFactory);
|
||||||
|
ArgumentNullException.ThrowIfNull(releaseUnpublishedGraphics);
|
||||||
|
ArgumentNullException.ThrowIfNull(inputFactory);
|
||||||
|
ArgumentNullException.ThrowIfNull(releaseUnpublishedInput);
|
||||||
|
ArgumentNullException.ThrowIfNull(publication);
|
||||||
|
|
||||||
|
var scope = new CompositionAcquisitionScope();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var graphicsLease = scope.Acquire(
|
||||||
|
"graphics API",
|
||||||
|
graphicsFactory,
|
||||||
|
releaseUnpublishedGraphics);
|
||||||
|
TGraphics graphics = graphicsLease.Publish(publication.PublishGraphics);
|
||||||
|
faultInjection?.Invoke(GameWindowPlatformAcquisitionPoint.GraphicsPublished);
|
||||||
|
|
||||||
|
var inputLease = scope.Acquire(
|
||||||
|
"input context",
|
||||||
|
inputFactory,
|
||||||
|
releaseUnpublishedInput);
|
||||||
|
TInput input = inputLease.Publish(publication.PublishInput);
|
||||||
|
faultInjection?.Invoke(GameWindowPlatformAcquisitionPoint.InputPublished);
|
||||||
|
|
||||||
|
scope.Complete();
|
||||||
|
return new GameWindowPlatformResult<TGraphics, TInput>(graphics, input);
|
||||||
|
}
|
||||||
|
catch (Exception failure)
|
||||||
|
{
|
||||||
|
scope.RollbackAndThrow(failure);
|
||||||
|
throw new System.Diagnostics.UnreachableException();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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