using AcDream.App.Rendering; namespace AcDream.App.Tests.Rendering; public sealed class ResourceCleanupGroupTests { [Fact] public void CleanupRunsInReverseOrderAndNeverReplaysSuccess() { var calls = new List(); var resources = new ResourceCleanupGroup(); int middleFailures = 1; resources.Add("first", () => calls.Add("first")); resources.Add("middle", () => { calls.Add("middle"); if (middleFailures-- > 0) throw new InvalidOperationException("middle failed"); }); resources.Add("last", () => calls.Add("last")); Assert.Throws(resources.RetryCleanup); Assert.Equal(["last", "middle", "first"], calls); resources.RetryCleanup(); resources.RetryCleanup(); Assert.True(resources.IsCleanupComplete); Assert.Equal(["last", "middle", "first", "middle"], calls); } [Fact] public void TransferAllLeavesPublishedResourcesUntouched() { int releaseCalls = 0; var resources = new ResourceCleanupGroup(); resources.Add("published", () => releaseCalls++); resources.TransferAll(); resources.RetryCleanup(); Assert.True(resources.IsCleanupComplete); Assert.Equal(0, releaseCalls); Assert.Throws(() => resources.Add("late", () => { })); } [Fact] public void FailedConstructionRollbackRetainsOnlyUnreleasedOwnership() { int releases = 0; bool releaseFails = true; var resources = new ResourceCleanupGroup(); resources.Add("retryable", () => { releases++; if (releaseFails) throw new InvalidOperationException("release failed"); }); GlResourceConstructionException failure = Assert.Throws(() => resources.RollbackConstructionAndThrow( "construction failed", new InvalidOperationException("original failure"))); Assert.False(failure.IsCleanupComplete); Assert.Equal(1, releases); releaseFails = false; failure.RetryCleanup(); failure.RetryCleanup(); Assert.True(failure.IsCleanupComplete); Assert.Equal(2, releases); } [Fact] public void WorldRenderFoundationConstructorsPublishEveryOwnedPrefix() { string root = FindRepoRoot(); string graphicsDevice = File.ReadAllText(Path.Combine( root, "src", "AcDream.App", "Rendering", "Wb", "OpenGLGraphicsDevice.cs")); string shader = File.ReadAllText(Path.Combine( root, "src", "AcDream.App", "Rendering", "Wb", "GLSLShader.cs")); string uniformBuffer = File.ReadAllText(Path.Combine( root, "src", "AcDream.App", "Rendering", "Wb", "ManagedGLUniformBuffer.cs")); AssertAppearsInOrder( graphicsDevice, "var resources = new ResourceCleanupGroup();", "InstanceVBO = CreateConstructionBuffer(resources", "WrapSampler = CreateConstructionSampler(", "ClampSampler = CreateConstructionSampler(", "_sceneDataBuffer = new ManagedGLUniformBuffer(", "resources.Add(", "\"WB scene-data uniform buffer\"", "InitializeSharedDebugResources(resources);", "resources.TransferAll();", "resources.RollbackConstructionAndThrow("); AssertAppearsInOrder( shader, "ShaderProgramConstruction.Build(", "resources.Add(\"WB shader program\"", "configure shader {Name} SceneData binding", "resources.TransferAll();", "resources.RollbackConstructionAndThrow("); AssertAppearsInOrder( uniformBuffer, "TrackedGlResource.CreateBuffer(", "resources.Add(", "TrackedGlResource.AllocateBufferStorage(", "resources.TransferAll();", "resources.RollbackConstructionAndThrow("); Assert.Contains("internal void DisposeImmediately()", uniformBuffer, StringComparison.Ordinal); } /// /// Campaign V slice V4a: TextRenderer's constructor no longer owns raw /// VAO/VBO/texture GL names through a ResourceCleanupGroup ledger — it /// creates exactly two device-owned resources (a pipeline, then the white /// fill texture) and, since a later failure could otherwise orphan an /// already-created pipeline, disposes whichever of the two it already /// created if the other throws. This replaces the pre-V4a assertion of /// the same name, which pinned the old multi-resource raw-GL shape /// (Shader + three flight-indexed VAO/VBO pairs + a hand-rolled white /// texture) that no longer exists. /// [Fact] public void TextRendererDisposesWhicheverConstructorResourceAlreadyExistsOnFailure() { string source = File.ReadAllText(Path.Combine( FindRepoRoot(), "src", "AcDream.App", "Rendering", "TextRenderer.cs")); AssertAppearsInOrder( source, "IGpuPipeline? pipeline = null;", "IGpuTexture? whiteTexture = null;", "try", "pipeline = device.CreatePipeline(", "whiteTexture = device.CreateTexture(", "device.RegisterTexture(whiteTexture, whiteSampler);", "catch", "whiteTexture?.Dispose();", "pipeline?.Dispose();", "throw;", "_pipeline = pipeline;", "_whiteTexture = whiteTexture;"); } [Fact] public void ReturnedNameIsRetainedWhenPostconditionAndFirstRollbackFail() { bool deleteFails = true; int deleteCalls = 0; GlResourceConstructionException failure = Assert.Throws(() => GlResourceCommand.CreateNameCore( "synthetic buffer", static () => { }, () => 42, () => throw new InvalidOperationException("postcondition failed"), name => { Assert.Equal(42u, name); deleteCalls++; if (deleteFails) throw new InvalidOperationException("delete failed"); })); Assert.False(failure.IsCleanupComplete); Assert.Equal(1, deleteCalls); deleteFails = false; failure.RetryCleanup(); failure.RetryCleanup(); Assert.True(failure.IsCleanupComplete); Assert.Equal(2, deleteCalls); } private static void AssertAppearsInOrder(string source, params string[] values) { int cursor = -1; foreach (string value in values) { int next = source.IndexOf(value, cursor + 1, StringComparison.Ordinal); Assert.True(next > cursor, $"Missing or out-of-order source fragment: {value}"); cursor = next; } } private static string FindRepoRoot() { DirectoryInfo? directory = new(AppContext.BaseDirectory); while (directory is not null) { if (File.Exists(Path.Combine(directory.FullName, "AcDream.slnx"))) return directory.FullName; directory = directory.Parent; } throw new DirectoryNotFoundException("Could not find AcDream.slnx."); } }