using AcDream.App.Rendering; using AcDream.App.Update; namespace AcDream.App.Tests.Rendering; public sealed class RuntimeResourceSlotTests { [Fact] public void OwnedSlotPublishesOnceAndRetriesOnlyFailedRelease() { var slot = new OwnedResourceSlot(); var resource = new RetryableResource(remainingFailures: 1); Assert.Same(resource, slot.Acquire(() => resource)); Assert.Same(resource, slot.Borrow()); Assert.Throws(() => slot.Acquire(() => new RetryableResource())); Assert.Throws(slot.Release); Assert.True(slot.HasResource); slot.Release(); slot.Release(); Assert.False(slot.HasResource); Assert.Equal(2, resource.DisposeCalls); } [Fact] public void TransferSlotKeepsFallbackUntilCompleteDestinationExists() { var slot = new TransferableResourceSlot(); var resource = slot.Acquire(() => new RetryableResource()); Assert.Throws(() => slot.Transfer(_ => throw new InvalidOperationException("destination failed"))); Assert.True(slot.HasFallback); Assert.Same(resource, slot.Borrow()); var owner = slot.Transfer(value => new ResourceOwner(value)); Assert.False(slot.HasFallback); slot.ReleaseFallback(); Assert.Equal(0, resource.DisposeCalls); owner.Dispose(); Assert.Equal(1, resource.DisposeCalls); } [Fact] public void TransferSlotReleasesOnlyFallbackOnPartialShutdown() { var fallback = new TransferableResourceSlot(); RetryableResource fallbackResource = fallback.Acquire( () => new RetryableResource()); fallback.ReleaseFallback(); fallback.ReleaseFallback(); Assert.Equal(1, fallbackResource.DisposeCalls); var transferred = new TransferableResourceSlot(); RetryableResource transferredResource = transferred.Acquire( () => new RetryableResource()); ResourceOwner owner = transferred.Transfer(value => new ResourceOwner(value)); owner.Dispose(); transferred.ReleaseFallback(); Assert.Equal(1, transferredResource.DisposeCalls); } [Fact] public void TransferSlotCoversAcquirePrepareAndPostTransferBindingPrefixes() { var acquisition = new TransferableResourceSlot(); Assert.Throws(() => acquisition.Acquire(() => throw new InvalidOperationException("create failed"))); Assert.False(acquisition.HasFallback); var preparation = new TransferableResourceSlot(); var prepared = new RetryableResource(remainingPrepareFailures: 1); Assert.Throws(() => preparation.AcquirePrepared(() => prepared, static value => value.Prepare())); Assert.True(preparation.HasFallback); Assert.Throws(() => preparation.Transfer(value => new ResourceOwner(value))); Assert.Same( prepared, preparation.AcquirePrepared( () => throw new InvalidOperationException("factory replayed"), static value => value.Prepare())); Assert.Equal(2, prepared.PrepareCalls); preparation.ReleaseFallback(); Assert.Equal(1, prepared.DisposeCalls); var binding = new TransferableResourceSlot(); RetryableResource transferred = binding.Acquire(() => new RetryableResource()); ResourceOwner? owner = null; Assert.Throws((Action)(() => { owner = binding.Transfer(value => new ResourceOwner(value)); throw new InvalidOperationException("binding failed"); })); Assert.False(binding.HasFallback); Assert.NotNull(owner); owner.Dispose(); binding.ReleaseFallback(); Assert.Equal(1, transferred.DisposeCalls); } [Fact] public void FrameGraphsPublishAtomicallyRouteAndBecomeSilentAfterWithdrawal() { var calls = new List(); var slot = new GameFrameGraphSlot(); Assert.False(slot.Tick(new UpdateFrameInput(0.25))); Assert.False(slot.Render(new RenderFrameInput(0.5, 800, 600), out _)); var update = new RecordingUpdateRoot(calls); var render = new RecordingRenderRoot(calls); slot.Publish(update, render); Assert.True(slot.IsPublished); Assert.Throws(() => slot.Publish(new RecordingUpdateRoot(calls), new RecordingRenderRoot(calls))); Assert.True(slot.Tick(new UpdateFrameInput(0.25))); Assert.True(slot.Render(new RenderFrameInput(0.5, 800, 600), out _)); Assert.Equal(["update:0.25", "render:0.5:800:600"], calls); slot.Withdraw(); slot.Withdraw(); Assert.False(slot.IsPublished); Assert.False(slot.Tick(new UpdateFrameInput(1))); Assert.False(slot.Render(new RenderFrameInput(1, 1, 1), out _)); Assert.Equal(2, calls.Count); } [Fact] public void WithdrawnFrameSlotAcceptsACompleteReplacementPair() { var calls = new List(); var slot = new GameFrameGraphSlot(); slot.Publish(new RecordingUpdateRoot(calls), new RecordingRenderRoot(calls)); slot.Withdraw(); slot.Publish(new RecordingUpdateRoot(calls), new RecordingRenderRoot(calls)); Assert.True(slot.Tick(new UpdateFrameInput(2))); Assert.Equal(["update:2"], calls); } [Fact] public void OwnedSlotRejectsReentrantAcquireWithoutLosingOuterResource() { var slot = new OwnedResourceSlot(); var outer = new RetryableResource(); int innerFactories = 0; Assert.Same(outer, slot.Acquire(() => { Assert.Throws(() => slot.Acquire(() => { innerFactories++; return new RetryableResource(); })); return outer; })); Assert.Equal(0, innerFactories); Assert.Same(outer, slot.Borrow()); slot.Release(); Assert.Equal(1, outer.DisposeCalls); } [Fact] public void OwnedSlotRejectsReentrantReleaseWithoutDoubleDisposal() { var slot = new OwnedResourceSlot(); var resource = new CallbackResource(() => Assert.Throws(slot.Release)); slot.Acquire(() => resource); slot.Release(); Assert.Equal(1, resource.DisposeCalls); Assert.False(slot.HasResource); } [Fact] public void TransferSlotRejectsNestedTransferAndPreservesOuterTransfer() { var slot = new TransferableResourceSlot(); var resource = slot.Acquire(() => new RetryableResource()); int nestedOwners = 0; ResourceOwner owner = slot.Transfer(value => { Assert.Throws(() => slot.Transfer(nested => { nestedOwners++; return new ResourceOwner(nested); })); return new ResourceOwner(value); }); Assert.Equal(0, nestedOwners); Assert.False(slot.HasFallback); owner.Dispose(); Assert.Equal(1, resource.DisposeCalls); } private sealed class RetryableResource( int remainingFailures = 0, int remainingPrepareFailures = 0) : IDisposable { private int _remainingFailures = remainingFailures; private int _remainingPrepareFailures = remainingPrepareFailures; public int DisposeCalls { get; private set; } public int PrepareCalls { get; private set; } public void Prepare() { PrepareCalls++; if (_remainingPrepareFailures-- > 0) throw new InvalidOperationException("prepare failed"); } public void Dispose() { DisposeCalls++; if (_remainingFailures-- > 0) throw new InvalidOperationException("synthetic release failure"); } } private sealed class ResourceOwner(RetryableResource resource) : IDisposable { public void Dispose() => resource.Dispose(); } private sealed class CallbackResource(Action onDispose) : IDisposable { public int DisposeCalls { get; private set; } public void Dispose() { DisposeCalls++; onDispose(); } } private sealed class RecordingUpdateRoot(List calls) : IGameUpdateFrameRoot { public void Tick(UpdateFrameInput input) => calls.Add($"update:{input.HostDeltaSeconds}"); } private sealed class RecordingRenderRoot(List calls) : IGameRenderFrameRoot { public RenderFrameOutcome Render(RenderFrameInput input) { calls.Add( $"render:{input.DeltaSeconds}:{input.ViewportWidth}:{input.ViewportHeight}"); return default; } } }