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 Steps = Design.steps 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 ``probe design steps expose the cumulative real-probe contract`` () = let steps = Design.probeSteps Assert.Equal(7, steps.Length) Assert.True([| "probe-01-bus"; "probe-02-boom"; "probe-03-rtg"; "probe-04-hga"; "probe-05-instruments"; "probe-06-mast"; "probe-07-livery" |] = (steps |> Array.map _.StepId)) Assert.True([| 0; 1; 2; 3; 4; 5; 6 |] = (steps |> Array.map _.Index)) Assert.Equal(22, steps[0].ObjectIds.Length) Assert.Equal(31, steps[1].ObjectIds.Length) Assert.Equal(43, steps[2].ObjectIds.Length) Assert.Equal(49, steps[3].ObjectIds.Length) Assert.Equal(55, steps[4].ObjectIds.Length) Assert.Equal(56, steps[5].ObjectIds.Length) Assert.Equal(56, steps[6].ObjectIds.Length) Assert.True(steps[0].ObjectIds |> Array.contains "probe.bus.electronics.decagon") Assert.True(steps[0].ObjectIds |> Array.contains "probe.bus.bay.panel.00") Assert.True(steps[2].ObjectIds |> Array.contains "probe.rtg.01.fin.02") Assert.True(steps[3].ObjectIds |> Array.contains "probe.hga.reflector") Assert.True(steps[4].ObjectIds |> Array.contains "probe.optical.aperture") Assert.True([| "probe.science.boom" |] = (steps[5].ObjectIds |> Array.except steps[4].ObjectIds)) Assert.True([||] = (steps[6].ObjectIds |> Array.except steps[5].ObjectIds)) Assert.Equal(6, steps[6].Changes.Length) Assert.True(steps[6].Changes |> Array.forall (fun change -> change.Kind = "update")) [] let ``design forProject selects steps by project id prefix`` () = Assert.True(Design.steps = Design.forProject "heritage-001") Assert.True(Design.probeSteps = Design.forProject "probe-001") Assert.True(Design.probeSteps = Design.forProject "probe-42") [] 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))