diff options
Diffstat (limited to 'tests')
14 files changed, 1153 insertions, 0 deletions
diff --git a/tests/SomhairlesDream.Cli.Tests/ArgumentsTests.fs b/tests/SomhairlesDream.Cli.Tests/ArgumentsTests.fs new file mode 100644 index 0000000..f55762e --- /dev/null +++ b/tests/SomhairlesDream.Cli.Tests/ArgumentsTests.fs @@ -0,0 +1,70 @@ +module SomhairlesDream.Cli.Tests.ArgumentsTests + +open Xunit +open SomhairlesDream.Cli + +let private expectOk = function + | Ok value -> value + | Error message -> failwith message + +let private expectError = function + | Error message -> message + | Ok _ -> failwith "expected an error" + +[<Fact>] +let ``run command applies documented defaults`` () = + match Arguments.parse [| "run" |] |> expectOk with + | Run options -> + Assert.Equal("heritage-001", options.Ids.ProjectId) + Assert.Equal("run-001", options.Ids.RunId) + Assert.Equal("base-000", options.Ids.BaseId) + Assert.Equal("target-003", options.Ids.TargetId) + Assert.Equal(".artifacts", options.OutputRoot) + Assert.Equal("stage", options.StageRoot) + Assert.False(options.Render) + Assert.NotEmpty(options.Python) + | Verify _ + | Help -> failwith "expected run command" + +[<Fact>] +let ``run command parses explicit ids and render flag`` () = + match + Arguments.parse + [| "run" + "--project-id" + "project-x" + "--run-id" + "run-x" + "--base-id" + "base-x" + "--target-id" + "target-x" + "--output-root" + "out" + "--stage-root" + "fixtures" + "--python" + "/tmp/python" + "--render" |] + |> expectOk with + | Run options -> + Assert.Equal("project-x", options.Ids.ProjectId) + Assert.Equal("run-x", options.Ids.RunId) + Assert.Equal("base-x", options.Ids.BaseId) + Assert.Equal("target-x", options.Ids.TargetId) + Assert.Equal("out", options.OutputRoot) + Assert.Equal("fixtures", options.StageRoot) + Assert.Equal("/tmp/python", options.Python) + Assert.True(options.Render) + | Verify _ + | Help -> failwith "expected run command" + +[<Fact>] +let ``verify command requires exactly one manifest path`` () = + match Arguments.parse [| "verify"; "run"; "extra" |] |> expectError with + | message -> Assert.Contains("manifest", message) + +[<Fact>] +let ``arguments reject unsafe resource ids`` () = + match Arguments.parse [| "run"; "--project-id"; "../escape" |] |> expectError with + | message -> Assert.Contains("resource id", message) diff --git a/tests/SomhairlesDream.Cli.Tests/SomhairlesDream.Cli.Tests.fsproj b/tests/SomhairlesDream.Cli.Tests/SomhairlesDream.Cli.Tests.fsproj new file mode 100644 index 0000000..461d5eb --- /dev/null +++ b/tests/SomhairlesDream.Cli.Tests/SomhairlesDream.Cli.Tests.fsproj @@ -0,0 +1,21 @@ +<Project Sdk="Microsoft.NET.Sdk"> + <PropertyGroup> + <TargetFramework>net8.0</TargetFramework> + <IsPackable>false</IsPackable> + <IsTestProject>true</IsTestProject> + </PropertyGroup> + <ItemGroup> + <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" /> + <PackageReference Include="xunit" Version="2.9.2" /> + <PackageReference Include="xunit.runner.visualstudio" Version="2.8.2"> + <PrivateAssets>all</PrivateAssets> + <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> + </PackageReference> + </ItemGroup> + <ItemGroup> + <ProjectReference Include="../../src/SomhairlesDream.Cli/SomhairlesDream.Cli.fsproj" /> + </ItemGroup> + <ItemGroup> + <Compile Include="ArgumentsTests.fs" /> + </ItemGroup> +</Project> diff --git a/tests/SomhairlesDream.Modeling.Tests/BridgeTests.fs b/tests/SomhairlesDream.Modeling.Tests/BridgeTests.fs new file mode 100644 index 0000000..d9c7347 --- /dev/null +++ b/tests/SomhairlesDream.Modeling.Tests/BridgeTests.fs @@ -0,0 +1,72 @@ +module SomhairlesDream.Modeling.Tests.BridgeTests + +open System +open System.IO +open Xunit +open SomhairlesDream.Modeling + +let private withTempDirectory action = + let path = Path.Combine(Path.GetTempPath(), "somhairles-dream-bridge-" + Guid.NewGuid().ToString("N")) + Directory.CreateDirectory(path) |> ignore + + try + action path + finally + if Directory.Exists(path) then + Directory.Delete(path, true) + +let private fixtureScript = + [| "import argparse" + "import json" + "import os" + "" + "parser = argparse.ArgumentParser()" + "parser.add_argument('--step-id', required=True)" + "parser.add_argument('--output', required=True)" + "parser.add_argument('--render-output')" + "parser.add_argument('--result-output')" + "args = parser.parse_args()" + "os.makedirs(os.path.dirname(args.output), exist_ok=True)" + "with open(args.output, 'wb') as stream:" + " stream.write(b'glTF')" + "result_path = args.result_output or args.output + '.result.json'" + "with open(result_path, 'w', encoding='utf-8') as stream:" + " json.dump({" + " 'ok': True," + " 'blenderVersion': 'test'," + " 'exportedAt': '2026-09-20T12:00:05Z'," + " 'objectIds': ['heritage.foundation', 'heritage.deck']," + " 'verifiedObjectIds': ['heritage.foundation', 'heritage.deck']," + " 'glbBytes': 4" + " }, stream)" + "with open(args.output + '.log', 'w', encoding='utf-8') as stream:" + " stream.write('fixture log retained separately\\n')" + "print('Blender informational output')" |] + |> String.concat Environment.NewLine + +[<Fact>] +let ``bridge reads result file instead of polluted stdout and retains process log`` () = + withTempDirectory (fun root -> + let blenderRoot = Path.Combine(root, "stage", "blender") + Directory.CreateDirectory(blenderRoot) |> ignore + File.WriteAllText(Path.Combine(blenderRoot, "fixture.py"), fixtureScript) + + let output = Path.Combine(root, "run", "step.glb") + let request = + { Step = Design.steps[0] + GlbPath = output + RenderPath = None + ResultPath = output + ".result.json" + LogPath = output + ".log" } + + let result = + (BlenderProcessBridge("python3", Path.Combine(root, "stage")) :> IArtifactBridge) + .Export(request, ignore) + + match result with + | Error message -> failwith message + | Ok value -> + Assert.Equal("test", value.BlenderVersion) + Assert.Equal(4L, value.GlbBytes) + Assert.True(File.Exists(output + ".log")) + Assert.Contains("Blender informational output", File.ReadAllText(output + ".log"))) diff --git a/tests/SomhairlesDream.Modeling.Tests/PipelineTests.fs b/tests/SomhairlesDream.Modeling.Tests/PipelineTests.fs new file mode 100644 index 0000000..7a5e3e1 --- /dev/null +++ b/tests/SomhairlesDream.Modeling.Tests/PipelineTests.fs @@ -0,0 +1,167 @@ +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 } + +[<Fact>] +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) + +[<Fact>] +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])) + +[<Fact>] +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))) + +[<Fact>] +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)) + +[<Fact>] +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)) + +[<Fact>] +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)) diff --git a/tests/SomhairlesDream.Modeling.Tests/SomhairlesDream.Modeling.Tests.fsproj b/tests/SomhairlesDream.Modeling.Tests/SomhairlesDream.Modeling.Tests.fsproj new file mode 100644 index 0000000..f7175b7 --- /dev/null +++ b/tests/SomhairlesDream.Modeling.Tests/SomhairlesDream.Modeling.Tests.fsproj @@ -0,0 +1,23 @@ +<Project Sdk="Microsoft.NET.Sdk"> + <PropertyGroup> + <TargetFramework>net8.0</TargetFramework> + <IsPackable>false</IsPackable> + <IsTestProject>true</IsTestProject> + </PropertyGroup> + <ItemGroup> + <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" /> + <PackageReference Include="xunit" Version="2.9.2" /> + <PackageReference Include="xunit.runner.visualstudio" Version="2.8.2"> + <PrivateAssets>all</PrivateAssets> + <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> + </PackageReference> + </ItemGroup> + <ItemGroup> + <ProjectReference Include="../../src/SomhairlesDream.Shared/SomhairlesDream.Shared.fsproj" /> + <ProjectReference Include="../../src/SomhairlesDream.Modeling/SomhairlesDream.Modeling.fsproj" /> + </ItemGroup> + <ItemGroup> + <Compile Include="PipelineTests.fs" /> + <Compile Include="BridgeTests.fs" /> + </ItemGroup> +</Project> diff --git a/tests/SomhairlesDream.Server.Tests/ApiTests.fs b/tests/SomhairlesDream.Server.Tests/ApiTests.fs new file mode 100644 index 0000000..cc8a3da --- /dev/null +++ b/tests/SomhairlesDream.Server.Tests/ApiTests.fs @@ -0,0 +1,235 @@ +module SomhairlesDream.Server.Tests.ApiTests + +open System +open System.IO +open System.Net +open System.Net.Http +open System.Text +open System.Text.Json +open System.Threading +open Microsoft.AspNetCore.Builder +open Microsoft.AspNetCore.Hosting +open Microsoft.AspNetCore.TestHost +open Xunit +open SomhairlesDream.Modeling +open SomhairlesDream.Server +open SomhairlesDream.Shared + +let private ids : RunIds = + { ProjectId = "heritage-001" + RunId = "run-api-001" + BaseId = "base-000" + TargetId = "target-003" } + +let private withTempDirectory action = + let path = Path.Combine(Path.GetTempPath(), "somhairles-dream-api-" + Guid.NewGuid().ToString("N")) + Directory.CreateDirectory(path) |> ignore + + try + action path + finally + if Directory.Exists(path) then + Directory.Delete(path, true) + +type private TestBridge() = + 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}") + + Ok + { BlenderVersion = "5.0.1" + ExportedAt = DateTimeOffset.Parse("2026-09-20T12:00:05Z") + ObjectIds = request.Step.ObjectIds + VerifiedObjectIds = request.Step.ObjectIds + GlbBytes = 4L + RenderPath = None + RenderedAt = None } + +type private BlockingBridge(gate: ManualResetEventSlim) = + interface IArtifactBridge with + member _.Export(request, _) = + gate.Wait() + (TestBridge() :> IArtifactBridge).Export(request, fun () -> ()) + +let private requestBody runId = + $"{{\"projectId\":\"{ids.ProjectId}\",\"runId\":\"{runId}\",\"baseId\":\"{ids.BaseId}\",\"targetId\":\"{ids.TargetId}\"}}" + +let private withAppUsing (bridgeFactory: unit -> IArtifactBridge) action = + withTempDirectory (fun root -> + let clock () = DateTimeOffset.Parse("2026-09-20T12:00:00Z") + let coordinator = + ArtifactRunCoordinator( + root, + bridgeFactory, + clock, + TimeSpan.FromSeconds 15. + ) + + let builder = WebApplication.CreateBuilder([||]) + builder.WebHost.UseTestServer() |> ignore + let app = builder.Build() + + ArtifactRunApi.register + app + { Coordinator = coordinator + Clock = clock + JsonOptions = JsonSerializerOptions(JsonSerializerDefaults.Web) } + + app.StartAsync().GetAwaiter().GetResult() + + try + action (app.GetTestClient()) coordinator + finally + app.StopAsync().GetAwaiter().GetResult() + app.DisposeAsync().AsTask().GetAwaiter().GetResult()) + +let private withApp action = withAppUsing (fun () -> TestBridge() :> IArtifactBridge) action + +let private waitFor predicate = + let deadline = DateTime.UtcNow.AddSeconds(5.) + let mutable matched = predicate () + + while not matched && DateTime.UtcNow < deadline do + Thread.Sleep(10) + matched <- predicate () + + matched + +[<Fact>] +let ``start endpoint accepts a run and conflicts on its second start`` () = + withApp (fun client _ -> + let first = + use content = new StringContent(requestBody ids.RunId, Encoding.UTF8, "application/json") + client.PostAsync("/api/runs/start", content).GetAwaiter().GetResult() + + Assert.Equal(HttpStatusCode.Accepted, first.StatusCode) + + let firstSnapshot = + first.Content.ReadAsStringAsync().GetAwaiter().GetResult() + |> fun body -> JsonSerializer.Deserialize<RunSnapshot>(body, JsonSerializerOptions(JsonSerializerDefaults.Web)) + + Assert.Equal(ids.ProjectId, firstSnapshot.ProjectId) + Assert.Equal(ids.RunId, firstSnapshot.RunId) + + use duplicateContent = new StringContent(requestBody ids.RunId, Encoding.UTF8, "application/json") + let duplicate = client.PostAsync("/api/runs/start", duplicateContent).GetAwaiter().GetResult() + + Assert.Equal(HttpStatusCode.Conflict, duplicate.StatusCode)) + +[<Fact>] +let ``start endpoint rejects invalid resource ids before creating a run`` () = + withApp (fun client coordinator -> + let body = requestBody "../outside" + use content = new StringContent(body, Encoding.UTF8, "application/json") + let response = client.PostAsync("/api/runs/start", content).GetAwaiter().GetResult() + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode) + Assert.True(coordinator.TryFind(ids.ProjectId, "../outside").IsNone)) + +[<Fact>] +let ``manifest and artifact endpoints expose a completed run and reject traversal`` () = + withApp (fun client coordinator -> + let runId = "run-api-complete" + + use content = new StringContent(requestBody runId, Encoding.UTF8, "application/json") + let start = client.PostAsync("/api/runs/start", content).GetAwaiter().GetResult() + Assert.Equal(HttpStatusCode.Accepted, start.StatusCode) + + let completed = + waitFor (fun () -> + coordinator.TryFind(ids.ProjectId, runId) + |> Option.exists (fun store -> store.Snapshot().Status = "complete")) + + Assert.True(completed) + + let manifest = + client + .GetAsync($"/api/artifacts/manifest?projectId={ids.ProjectId}&runId={runId}") + .GetAwaiter() + .GetResult() + + Assert.Equal(HttpStatusCode.OK, manifest.StatusCode) + let manifestBody = manifest.Content.ReadAsStringAsync().GetAwaiter().GetResult() + Assert.Contains("\"schemaVersion\":1", manifestBody) + + let glbPath = Uri.EscapeDataString("steps/01-foundation.glb") + let artifact = + client + .GetAsync($"/api/artifacts/file?projectId={ids.ProjectId}&runId={runId}&path={glbPath}") + .GetAwaiter() + .GetResult() + + Assert.Equal(HttpStatusCode.OK, artifact.StatusCode) + + let traversalPath = Uri.EscapeDataString("../manifest.json") + let traversal = + client + .GetAsync($"/api/artifacts/file?projectId={ids.ProjectId}&runId={runId}&path={traversalPath}") + .GetAwaiter() + .GetResult() + + Assert.Equal(HttpStatusCode.BadRequest, traversal.StatusCode)) + +[<Fact>] +let ``manifest endpoint reports active runs as conflicts`` () = + let gate = new ManualResetEventSlim(false) + + try + withAppUsing + (fun () -> BlockingBridge(gate) :> IArtifactBridge) + (fun client coordinator -> + let runId = "run-api-active" + use content = new StringContent(requestBody runId, Encoding.UTF8, "application/json") + let start = client.PostAsync("/api/runs/start", content).GetAwaiter().GetResult() + Assert.Equal(HttpStatusCode.Accepted, start.StatusCode) + + let manifest = + client + .GetAsync($"/api/artifacts/manifest?projectId={ids.ProjectId}&runId={runId}") + .GetAwaiter() + .GetResult() + + Assert.Equal(HttpStatusCode.Conflict, manifest.StatusCode) + gate.Set() + + Assert.True( + waitFor (fun () -> + coordinator.TryFind(ids.ProjectId, runId) + |> Option.exists (fun store -> store.Snapshot().Status = "complete"))) + ) + finally + gate.Set() + gate.Dispose() + +[<Fact>] +let ``events endpoint streams only the selected run`` () = + withApp (fun client _ -> + let runId = "run-api-events" + use content = new StringContent(requestBody runId, Encoding.UTF8, "application/json") + let start = client.PostAsync("/api/runs/start", content).GetAwaiter().GetResult() + Assert.Equal(HttpStatusCode.Accepted, start.StatusCode) + + use response = + client + .GetAsync( + $"/api/runs/events?projectId={ids.ProjectId}&runId={runId}", + HttpCompletionOption.ResponseHeadersRead + ) + .GetAwaiter() + .GetResult() + + Assert.Equal(HttpStatusCode.OK, response.StatusCode) + use reader = new StreamReader(response.Content.ReadAsStream()) + let line = reader.ReadLine() + Assert.StartsWith("data: ", line) + Assert.Contains(runId, line) + + let unknown = + client + .GetAsync($"/api/runs/events?projectId={ids.ProjectId}&runId=run-api-missing") + .GetAwaiter() + .GetResult() + + Assert.Equal(HttpStatusCode.NotFound, unknown.StatusCode)) diff --git a/tests/SomhairlesDream.Server.Tests/CoordinatorTests.fs b/tests/SomhairlesDream.Server.Tests/CoordinatorTests.fs new file mode 100644 index 0000000..e9689bb --- /dev/null +++ b/tests/SomhairlesDream.Server.Tests/CoordinatorTests.fs @@ -0,0 +1,83 @@ +module SomhairlesDream.Server.Tests.CoordinatorTests + +open System +open System.IO +open System.Threading +open Xunit +open SomhairlesDream.Modeling +open SomhairlesDream.Server +open SomhairlesDream.Shared + +let private ids : RunIds = + { ProjectId = "heritage-001" + RunId = "run-20260920-001" + BaseId = "heritage" + TargetId = "heritage-001" } + +let private withTempDirectory action = + let path = Path.Combine(Path.GetTempPath(), "somhairles-dream-server-" + Guid.NewGuid().ToString("N")) + Directory.CreateDirectory(path) |> ignore + + try + action path + finally + if Directory.Exists(path) then + Directory.Delete(path, true) + +type private TestBridge() = + 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}") + + Ok + { BlenderVersion = "5.0.1" + ExportedAt = DateTimeOffset.Parse("2026-09-20T12:00:05Z") + ObjectIds = request.Step.ObjectIds + VerifiedObjectIds = request.Step.ObjectIds + GlbBytes = 4L + RenderPath = None + RenderedAt = None } + +let private waitFor predicate = + let deadline = DateTime.UtcNow.AddSeconds(5.) + let mutable matched = predicate () + + while not matched && DateTime.UtcNow < deadline do + Thread.Sleep(10) + matched <- predicate () + + matched + +[<Fact>] +let ``coordinator starts one shared pipeline and publishes its manifest`` () = + withTempDirectory (fun root -> + let clock () = DateTimeOffset.Parse("2026-09-20T12:00:00Z") + let coordinator = + ArtifactRunCoordinator( + root, + (fun () -> TestBridge() :> IArtifactBridge), + clock, + TimeSpan.FromSeconds 15. + ) + + match coordinator.Start(ids, false) with + | Error message -> failwith message + | Ok(Accepted snapshot) -> + Assert.Equal(ids.ProjectId, snapshot.ProjectId) + Assert.Equal(ids.RunId, snapshot.RunId) + | Ok(Conflict _) -> failwith "expected the first start to be accepted" + + let completed = + waitFor (fun () -> + coordinator.TryFind(ids.ProjectId, ids.RunId) + |> Option.exists (fun store -> store.Snapshot().Status = "complete")) + + Assert.True(completed) + + match coordinator.Manifest(ids.ProjectId, ids.RunId) with + | Error failure -> failwith $"manifest lookup failed: {failure}" + | Ok manifest -> + Assert.Equal("complete", manifest.Status) + Assert.Equal(3, manifest.Steps.Length)) diff --git a/tests/SomhairlesDream.Server.Tests/SomhairlesDream.Server.Tests.fsproj b/tests/SomhairlesDream.Server.Tests/SomhairlesDream.Server.Tests.fsproj new file mode 100644 index 0000000..88e85f0 --- /dev/null +++ b/tests/SomhairlesDream.Server.Tests/SomhairlesDream.Server.Tests.fsproj @@ -0,0 +1,25 @@ +<Project Sdk="Microsoft.NET.Sdk"> + <PropertyGroup> + <TargetFramework>net8.0</TargetFramework> + <IsPackable>false</IsPackable> + <IsTestProject>true</IsTestProject> + </PropertyGroup> + <ItemGroup> + <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" /> + <PackageReference Include="Microsoft.AspNetCore.TestHost" Version="8.0.12" /> + <PackageReference Include="xunit" Version="2.9.2" /> + <PackageReference Include="xunit.runner.visualstudio" Version="2.8.2"> + <PrivateAssets>all</PrivateAssets> + <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> + </PackageReference> + </ItemGroup> + <ItemGroup> + <ProjectReference Include="../../src/SomhairlesDream.Server/SomhairlesDream.Server.fsproj" /> + <ProjectReference Include="../../src/SomhairlesDream.Modeling/SomhairlesDream.Modeling.fsproj" /> + </ItemGroup> + <ItemGroup> + <Compile Include="StoreTests.fs" /> + <Compile Include="CoordinatorTests.fs" /> + <Compile Include="ApiTests.fs" /> + </ItemGroup> +</Project> diff --git a/tests/SomhairlesDream.Server.Tests/StoreTests.fs b/tests/SomhairlesDream.Server.Tests/StoreTests.fs new file mode 100644 index 0000000..4aec16f --- /dev/null +++ b/tests/SomhairlesDream.Server.Tests/StoreTests.fs @@ -0,0 +1,236 @@ +namespace SomhairlesDream.Server.Tests + +open System +open Xunit +open SomhairlesDream.Shared +open SomhairlesDream.Server + +module StoreTests = + let private at hour = DateTimeOffset(2026, 9, 20, hour, 0, 0, 0, TimeSpan.Zero) + + let private artifactIds : RunIds = + { ProjectId = "heritage-001" + RunId = "run-20260920-001" + BaseId = "base-000" + TargetId = "target-003" } + + let private mesh version label = + { Version = version + Label = label + Vertices = + [| { X = 0.; Y = 0.; Z = 0. } + { X = float version; Y = 1.; Z = 0. } |] + Faces = [||] } + + [<Fact>] + let ``store publishes the latest state atomically`` () = + let store = RunStore("heritage-001", "run-001", TimeSpan.FromMinutes 2.) + + let running = store.Start(at 9) + let published = store.Publish(at 9, mesh running.TargetVersion "主承力骨架") + + Assert.Equal(RunStatus.Succeeded, published.Status) + Assert.Equal(published, store.Snapshot()) + Assert.Equal(1, published.CurrentVersion) + + [<Fact>] + let ``store marks an unresponsive run stale when observed`` () = + let store = RunStore("heritage-001", "run-001", TimeSpan.FromMinutes 2.) + store.Start(at 9) |> ignore + + let observed = store.Observe(at 10) + + Assert.Equal(RunStatus.Stale, observed.Status) + Assert.Equal("心跳超时,等待重连", observed.Message) + + [<Fact>] + let ``store sends an initial snapshot and every transition to subscribers`` () = + let store = RunStore("heritage-001", "run-001", TimeSpan.FromMinutes 2.) + let reader = store.Subscribe() + + let initial = reader.ReadAsync().AsTask().GetAwaiter().GetResult() + let running = store.Start(at 9) + let update = reader.ReadAsync().AsTask().GetAwaiter().GetResult() + + Assert.Equal(RunStatus.Idle, initial.Status) + Assert.Equal(running, update) + + [<Fact>] + let ``artifact store applies lifecycle events to a run snapshot`` () = + let store = ArtifactRunStore(artifactIds, TimeSpan.FromSeconds 15., at 9) + + let start = + RunEvent.Start + { Ids = artifactIds + At = at 9 + TotalSteps = 3 } + + let checkpoint = + RunEvent.Checkpoint + { Ids = artifactIds + At = at 9 + StepId = "01-foundation" + StepIndex = 0 + TotalSteps = 3 + Message = "artifact verified" + ArtifactPath = "steps/01-foundation.glb" } + + Assert.True(store.Apply(start) |> Result.isOk) + Assert.True(store.Apply(checkpoint) |> Result.isOk) + + let snapshot = store.Snapshot() + + Assert.Equal("running", snapshot.Status) + Assert.Equal(artifactIds.BaseId, snapshot.BaseId) + Assert.Equal(artifactIds.TargetId, snapshot.TargetId) + Assert.Equal(Some "01-foundation", snapshot.CurrentStepId) + Assert.Equal(0, snapshot.CurrentStepIndex) + Assert.Equal(3, snapshot.TotalSteps) + Assert.True(snapshot.ManifestPath.IsNone) + Assert.False(snapshot.IsStale) + + [<Fact>] + let ``artifact store derives staleness without changing lifecycle status`` () = + let store = ArtifactRunStore(artifactIds, TimeSpan.FromSeconds 15., at 9) + + store.Apply( + RunEvent.Start + { Ids = artifactIds + At = at 9 + TotalSteps = 3 } + ) + |> ignore + + let snapshot = store.Observe(at 10) + + Assert.Equal("running", snapshot.Status) + Assert.True(snapshot.IsStale) + + [<Fact>] + let ``artifact store rejects lifecycle events after completion`` () = + let store = ArtifactRunStore(artifactIds, TimeSpan.FromSeconds 15., at 9) + + store.Apply( + RunEvent.Start + { Ids = artifactIds + At = at 9 + TotalSteps = 3 } + ) + |> ignore + + for index, stepId in [| 0, "01-foundation"; 1, "02-frame"; 2, "03-cabin" |] do + store.Apply( + RunEvent.Checkpoint + { Ids = artifactIds + At = at 9 + StepId = stepId + StepIndex = index + TotalSteps = 3 + Message = "artifact verified" + ArtifactPath = $"steps/{stepId}.glb" } + ) + |> ignore + + store.Apply( + RunEvent.Complete + { Ids = artifactIds + At = at 9 + ManifestPath = "manifest.json" } + ) + |> ignore + + let result = + store.Apply( + RunEvent.Heartbeat + { Ids = artifactIds + At = at 9 + Message = "late heartbeat" } + ) + + Assert.True(result |> Result.isError) + + [<Fact>] + let ``artifact store rejects completion before every checkpoint`` () = + let store = ArtifactRunStore(artifactIds, TimeSpan.FromSeconds 15., at 9) + + store.Apply( + RunEvent.Start + { Ids = artifactIds + At = at 9 + TotalSteps = 3 } + ) + |> ignore + + let result = + store.Apply( + RunEvent.Complete + { Ids = artifactIds + At = at 9 + ManifestPath = "manifest.json" } + ) + + Assert.True(result |> Result.isError) + Assert.Equal("running", store.Snapshot().Status) + + [<Fact>] + let ``artifact store records a pipeline failure before start`` () = + let store = ArtifactRunStore(artifactIds, TimeSpan.FromSeconds 15., at 9) + + let result = + store.Apply( + RunEvent.Fail + { Ids = artifactIds + At = at 9 + StepId = None + Message = "run directory already exists" } + ) + + Assert.True(result |> Result.isOk) + Assert.Equal("failed", store.Snapshot().Status) + Assert.Equal(Some "run directory already exists", store.Snapshot().Error) + + [<Fact>] + let ``artifact store accepts completion after ordered checkpoints`` () = + let store = ArtifactRunStore(artifactIds, TimeSpan.FromSeconds 15., at 9) + + store.Apply( + RunEvent.Start + { Ids = artifactIds + At = at 9 + TotalSteps = 3 } + ) + |> ignore + + for index, stepId in [| 0, "01-foundation"; 1, "02-frame"; 2, "03-cabin" |] do + store.Apply( + RunEvent.Checkpoint + { Ids = artifactIds + At = at 9 + StepId = stepId + StepIndex = index + TotalSteps = 3 + Message = "artifact verified" + ArtifactPath = $"steps/{stepId}.glb" } + ) + |> ignore + + let result = + store.Apply( + RunEvent.Complete + { Ids = artifactIds + At = at 9 + ManifestPath = "manifest.json" } + ) + + Assert.True(result |> Result.isOk) + Assert.Equal("complete", store.Snapshot().Status) + + [<Fact>] + let ``artifact registry atomically creates only one store for a run key`` () = + let registry = ArtifactRunRegistry(TimeSpan.FromSeconds 15., fun () -> at 9) + + let first = registry.TryCreate(artifactIds) + let duplicate = registry.TryCreate(artifactIds) + + Assert.True(first.IsSome) + Assert.True(duplicate.IsNone) diff --git a/tests/SomhairlesDream.Shared.Tests/ArtifactContractTests.fs b/tests/SomhairlesDream.Shared.Tests/ArtifactContractTests.fs new file mode 100644 index 0000000..e4f0c86 --- /dev/null +++ b/tests/SomhairlesDream.Shared.Tests/ArtifactContractTests.fs @@ -0,0 +1,73 @@ +module SomhairlesDream.Shared.Tests.ArtifactContractTests + +open System +open Xunit +open SomhairlesDream.Shared + +let private ids : RunIds = + { ProjectId = "heritage-001" + RunId = "run-20260920-001" + BaseId = "heritage" + TargetId = "heritage-001" } + +[<Fact>] +let ``resource ids reject path traversal and separators`` () = + Assert.True(Ids.validateResourceId "heritage-001" |> Result.isOk) + Assert.True(Ids.validateResourceId "../outside" |> Result.isError) + Assert.True(Ids.validateResourceId "heritage/001" |> Result.isError) + Assert.True(Ids.validateResourceId "heritage_001" |> Result.isError) + +[<Fact>] +let ``object ids use the stable dotted namespace`` () = + Assert.True(Ids.validateObjectId "heritage.foundation" |> Result.isOk) + Assert.True(Ids.validateObjectId "heritage/frame.left" |> Result.isError) + Assert.True(Ids.validateObjectId "heritage.foundation/../../outside" |> Result.isError) + +[<Fact>] +let ``start event serializes as flat camel-case json`` () = + let event = + RunEvent.Start + { Ids = ids + At = DateTimeOffset.Parse("2026-09-20T12:00:00Z") + TotalSteps = 3 } + + let json = RunEvent.toJson event + + Assert.Contains("\"kind\":\"start\"", json) + Assert.Contains("\"projectId\":\"heritage-001\"", json) + Assert.Contains("\"runId\":\"run-20260920-001\"", json) + Assert.Contains("\"totalSteps\":3", json) + Assert.DoesNotContain("\"stepId\":null", json) + +[<Fact>] +let ``checkpoint event includes its step and artifact path`` () = + let event = + RunEvent.Checkpoint + { Ids = ids + At = DateTimeOffset.Parse("2026-09-20T12:00:05Z") + StepId = "01-foundation" + StepIndex = 1 + TotalSteps = 3 + Message = "foundation exported" + ArtifactPath = "steps/01-foundation.glb" } + + let wire = RunEvent.toWire event + + Assert.Equal("checkpoint", wire.Kind) + Assert.Equal(Some "01-foundation", wire.StepId) + Assert.Equal(Some 3, wire.TotalSteps) + Assert.Equal(Some "steps/01-foundation.glb", wire.ArtifactPath) + +[<Fact>] +let ``checkpoint event serializes total steps`` () = + let event = + RunEvent.Checkpoint + { Ids = ids + At = DateTimeOffset.Parse("2026-09-20T12:00:05Z") + StepId = "01-foundation" + StepIndex = 0 + TotalSteps = 3 + Message = "foundation exported" + ArtifactPath = "steps/01-foundation.glb" } + + Assert.Contains("\"totalSteps\":3", RunEvent.toJson event) diff --git a/tests/SomhairlesDream.Shared.Tests/FrontendContractTests.fs b/tests/SomhairlesDream.Shared.Tests/FrontendContractTests.fs new file mode 100644 index 0000000..d961d8b --- /dev/null +++ b/tests/SomhairlesDream.Shared.Tests/FrontendContractTests.fs @@ -0,0 +1,36 @@ +module SomhairlesDream.Shared.Tests.FrontendContractTests + +open System.IO +open Xunit + +let private root = Path.GetFullPath(Path.Combine(__SOURCE_DIRECTORY__, "..", "..")) + +let private readRelative path = File.ReadAllText(Path.Combine(root, path)) + +let private assertBrowserContract contents = + Assert.Contains("/api/runs/start", contents) + Assert.Contains("/api/runs/events?projectId=", contents) + Assert.Contains("artifact-manifest-link", contents) + Assert.DoesNotContain("/api/events", contents) + +[<Fact>] +let ``frontend source and bundle use the selected artifact run lifecycle`` () = + readRelative "src/SomhairlesDream.Frontend/App.fs" + |> assertBrowserContract + + readRelative "public/App.js" + |> assertBrowserContract + + let html = readRelative "public/index.html" + Assert.Contains("id=\"run-start-button\"", html) + Assert.Contains("id=\"artifact-manifest-link\"", html) + +[<Fact>] +let ``solution contains every project in the artifact pipeline`` () = + let solution = readRelative "SomhairlesDream.sln" + + [ "SomhairlesDream.Modeling" + "SomhairlesDream.Cli" + "SomhairlesDream.Modeling.Tests" + "SomhairlesDream.Cli.Tests" ] + |> List.iter (fun project -> Assert.Contains(project, solution)) diff --git a/tests/SomhairlesDream.Shared.Tests/ReplayTests.fs b/tests/SomhairlesDream.Shared.Tests/ReplayTests.fs new file mode 100644 index 0000000..317fabe --- /dev/null +++ b/tests/SomhairlesDream.Shared.Tests/ReplayTests.fs @@ -0,0 +1,26 @@ +namespace SomhairlesDream.Shared.Tests + +open Xunit +open SomhairlesDream.Shared + +module ReplayTests = + [<Fact>] + let ``reconstruction contains three visibly different geometry frames`` () = + let frames = Replay.frames () + + Assert.Equal(3, frames.Length) + Assert.True([| 1; 2; 3 |] = (frames |> Array.map (fun frame -> frame.Version))) + Assert.Equal("重建起点", frames[0].Label) + Assert.Equal("主承力骨架", frames[1].Label) + Assert.Equal("舱段重构", frames[2].Label) + Assert.False(frames[0].Mesh.Vertices = frames[1].Mesh.Vertices) + Assert.False(frames[1].Mesh.Vertices = frames[2].Mesh.Vertices) + + [<Fact>] + let ``mesh snapshots expose indexed triangle buffers`` () = + let mesh = (Replay.frames ()).[2].Mesh + let positions = MeshData.positionData mesh + let indices = MeshData.triangleIndices mesh + + Assert.Equal(mesh.Vertices.Length * 3, positions.Length) + Assert.Equal(45, indices.Length) diff --git a/tests/SomhairlesDream.Shared.Tests/RunStateTests.fs b/tests/SomhairlesDream.Shared.Tests/RunStateTests.fs new file mode 100644 index 0000000..08e4e89 --- /dev/null +++ b/tests/SomhairlesDream.Shared.Tests/RunStateTests.fs @@ -0,0 +1,62 @@ +namespace SomhairlesDream.Shared.Tests + +open System +open Xunit +open SomhairlesDream.Shared + +module RunStateTests = + let private at hour = DateTimeOffset(2026, 9, 20, hour, 0, 0, 0, TimeSpan.Zero) + + [<Fact>] + let ``a new run is idle with no design work scheduled`` () = + let snapshot = RunState.initial "heritage-001" "run-001" (at 8) + + Assert.Equal("heritage-001", snapshot.ProjectId) + Assert.Equal("run-001", snapshot.RunId) + Assert.Equal(RunStatus.Idle, snapshot.Status) + Assert.Equal(0, snapshot.CurrentVersion) + Assert.Equal(0, snapshot.TargetVersion) + Assert.False(snapshot.NextDesignRunScheduled) + Assert.Equal("等待设计运行", snapshot.Message) + + [<Fact>] + let ``starting a run increments the target version and heartbeat keeps it running`` () = + let started = + RunState.initial "heritage-001" "run-001" (at 8) + |> RunState.start (at 9) + + let alive = RunState.heartbeat (at 9) started + + Assert.Equal(RunStatus.Running, alive.Status) + Assert.Equal(1, alive.TargetVersion) + Assert.Equal(Some(at 9), alive.HeartbeatAt) + + [<Fact>] + let ``a running run becomes stale after the heartbeat window`` () = + let started = + RunState.initial "heritage-001" "run-001" (at 8) + |> RunState.start (at 9) + + let stale = RunState.statusAt (TimeSpan.FromMinutes 2.) (at 10) started + + Assert.Equal(RunStatus.Stale, stale.Status) + Assert.Equal("心跳超时,等待重连", stale.Message) + + [<Fact>] + let ``publishing a mesh records the current version and makes the run successful`` () = + let started = + RunState.initial "heritage-001" "run-001" (at 8) + |> RunState.start (at 9) + + let mesh = + { Version = 1 + Label = "基础结构" + Vertices = [| { X = 0.; Y = 0.; Z = 0. } |] + Faces = [||] } + + let published = RunState.publish (at 9) mesh started + + Assert.Equal(RunStatus.Succeeded, published.Status) + Assert.Equal(1, published.CurrentVersion) + Assert.Equal(Some mesh, published.Mesh) + Assert.Equal("已发布基础结构", published.Message) diff --git a/tests/SomhairlesDream.Shared.Tests/SomhairlesDream.Shared.Tests.fsproj b/tests/SomhairlesDream.Shared.Tests/SomhairlesDream.Shared.Tests.fsproj new file mode 100644 index 0000000..d0e6130 --- /dev/null +++ b/tests/SomhairlesDream.Shared.Tests/SomhairlesDream.Shared.Tests.fsproj @@ -0,0 +1,24 @@ +<Project Sdk="Microsoft.NET.Sdk"> + <PropertyGroup> + <TargetFramework>net8.0</TargetFramework> + <IsPackable>false</IsPackable> + <IsTestProject>true</IsTestProject> + </PropertyGroup> + <ItemGroup> + <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" /> + <PackageReference Include="xunit" Version="2.9.2" /> + <PackageReference Include="xunit.runner.visualstudio" Version="2.8.2"> + <PrivateAssets>all</PrivateAssets> + <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> + </PackageReference> + </ItemGroup> + <ItemGroup> + <ProjectReference Include="../../src/SomhairlesDream.Shared/SomhairlesDream.Shared.fsproj" /> + </ItemGroup> + <ItemGroup> + <Compile Include="RunStateTests.fs" /> + <Compile Include="ReplayTests.fs" /> + <Compile Include="ArtifactContractTests.fs" /> + <Compile Include="FrontendContractTests.fs" /> + </ItemGroup> +</Project> |
