module SomhairlesDream.Modeling.Tests.PipelineTests open System open System.IO open Xunit open SomhairlesDream.Modeling open SomhairlesDream.Shared let private ids : RunIds = { ProjectId = "heritage-001" RunId = "run-20260920-001" BaseId = "heritage" TargetId = "heritage-001" } let private expectOk = function | Ok value -> value | Error message -> failwith message let private expectError = function | Error message -> message | Ok _ -> failwith "expected an error" let private withTempDirectory action = let path = Path.Combine(Path.GetTempPath(), "somhairles-dream-" + Guid.NewGuid().ToString("N")) Directory.CreateDirectory(path) |> ignore try action path finally if Directory.Exists(path) then Directory.Delete(path, true) type private TestBridge(?wrongObjectIds: bool) = interface IArtifactBridge with member _.Export(request, _) = File.WriteAllBytes(request.GlbPath, [| 0x67uy; 0x6Cuy; 0x54uy; byte request.Step.Index |]) Directory.CreateDirectory(Path.GetDirectoryName(request.LogPath)) |> ignore File.WriteAllText(request.LogPath, $"bridge log for {request.Step.StepId}") match request.RenderPath with | Some path -> File.WriteAllBytes(path, [| 0x89uy; 0x50uy; 0x4Euy; 0x47uy |]) | None -> () let objectIds = if defaultArg wrongObjectIds false then [| "unexpected.object" |] else request.Step.ObjectIds Ok { BlenderVersion = "5.0.1" ExportedAt = DateTimeOffset.Parse("2026-09-20T12:00:05Z") ObjectIds = objectIds VerifiedObjectIds = objectIds GlbBytes = 4L RenderPath = request.RenderPath RenderedAt = request.RenderPath |> Option.map (fun _ -> DateTimeOffset.Parse("2026-09-20T12:00:06Z")) } let private options root bridge = { PipelineOptions.ArtifactRoot = root Ids = ids Render = true Bridge = bridge Clock = fun () -> DateTimeOffset.Parse("2026-09-20T12:00:00Z") OnEvent = ignore } [] let ``design steps expose the fixed ordered object contract`` () = let steps = Design.steps Assert.Equal(3, steps.Length) Assert.True([| "01-foundation"; "02-frame"; "03-cabin" |] = (steps |> Array.map _.StepId)) Assert.True([| 0; 1; 2 |] = (steps |> Array.map _.Index)) Assert.True([| "heritage.foundation"; "heritage.deck" |] = steps[0].ObjectIds) Assert.True( [| "heritage.foundation" "heritage.deck" "heritage.frame.left" "heritage.frame.right" "heritage.spine" |] = steps[1].ObjectIds) Assert.True( [| "heritage.foundation" "heritage.deck" "heritage.frame.left" "heritage.frame.right" "heritage.spine" "heritage.cabin" "heritage.crossbeam" |] = steps[2].ObjectIds) [] let ``pipeline commits each step and writes manifest after complete event`` () = withTempDirectory (fun root -> let manifest = Pipeline.run (options root (TestBridge() :> IArtifactBridge)) |> expectOk let runDirectory = Path.Combine(root, ids.ProjectId, ids.RunId) let eventsPath = Path.Combine(runDirectory, "events.ndjson") let manifestPath = Path.Combine(runDirectory, "manifest.json") Assert.Equal("complete", manifest.Status) Assert.Equal(3, manifest.Steps.Length) Assert.True(File.Exists(eventsPath)) Assert.True(File.Exists(manifestPath)) let verifiedManifest: ArtifactManifest = ArtifactVerifier.verify manifestPath |> expectOk Assert.Equal(manifest.RunId, verifiedManifest.RunId) Assert.Equal(manifest.Steps.Length, verifiedManifest.Steps.Length) Assert.False(Directory.Exists(Path.Combine(runDirectory, ".staging"))) for step in manifest.Steps do Assert.True(File.Exists(Path.Combine(runDirectory, step.ArtifactPath.Replace('/', Path.DirectorySeparatorChar)))) Assert.Equal($"logs/{step.StepId}.log", step.LogPath) Assert.True(File.Exists(Path.Combine(runDirectory, step.LogPath.Replace('/', Path.DirectorySeparatorChar)))) Assert.True(step.Render |> Option.exists (fun render -> File.Exists(Path.Combine(runDirectory, render.ArtifactPath.Replace('/', Path.DirectorySeparatorChar))))) Assert.True(step.ObjectIds = step.VerifiedObjectIds) let events = File.ReadAllLines(eventsPath) Assert.Contains("\"kind\":\"start\"", events[0]) Assert.Contains("\"kind\":\"complete\"", events[events.Length - 1])) [] let ``pipeline rejects a pre-existing run directory without overwriting it`` () = withTempDirectory (fun root -> let runDirectory = Path.Combine(root, ids.ProjectId, ids.RunId) Directory.CreateDirectory(runDirectory) |> ignore let marker = Path.Combine(runDirectory, "marker.txt") File.WriteAllText(marker, "keep") let result = Pipeline.run (options root (TestBridge() :> IArtifactBridge)) Assert.True(Result.isError result) Assert.Equal("keep", File.ReadAllText(marker))) [] let ``pipeline fails before manifest commit when bridge object ids differ`` () = withTempDirectory (fun root -> let result = Pipeline.run (options root (TestBridge(true) :> IArtifactBridge)) let runDirectory = Path.Combine(root, ids.ProjectId, ids.RunId) Assert.True(Result.isError result) Assert.False(File.Exists(Path.Combine(runDirectory, "manifest.json"))) Assert.Contains("object ids", result |> expectError)) [] let ``manifest verifier rejects a tampered artifact hash`` () = withTempDirectory (fun root -> Pipeline.run (options root (TestBridge() :> IArtifactBridge)) |> expectOk |> ignore let runDirectory = Path.Combine(root, ids.ProjectId, ids.RunId) let artifactPath = Path.Combine(runDirectory, "steps", "01-foundation.glb") let bytes = File.ReadAllBytes(artifactPath) bytes[0] <- bytes[0] ^^^ 1uy File.WriteAllBytes(artifactPath, bytes) let result = ArtifactVerifier.verify (Path.Combine(runDirectory, "manifest.json")) Assert.True(Result.isError result) Assert.Contains("hash", result |> expectError)) [] let ``manifest verifier rejects a missing retained process log`` () = withTempDirectory (fun root -> Pipeline.run (options root (TestBridge() :> IArtifactBridge)) |> expectOk |> ignore let runDirectory = Path.Combine(root, ids.ProjectId, ids.RunId) File.Delete(Path.Combine(runDirectory, "logs", "01-foundation.log")) let result = ArtifactVerifier.verify (Path.Combine(runDirectory, "manifest.json")) Assert.True(Result.isError result) Assert.Contains("log", result |> expectError))