From 776b8bea4777b9fc280a92f09dfb5aa197cb72cc Mon Sep 17 00:00:00 2001
From: Coasterpete <83011124+Coasterpete@users.noreply.github.com>
Date: Wed, 22 Jul 2026 14:51:53 -0400
Subject: [PATCH 01/11] M167.1: measure authoring compilation pipeline
---
.../EditorViewportPipelineMeasurement.cs | 56 ++++++
.../Services/Viewport/TrackSamplingService.cs | 15 ++
...M167CompilationPipelineMeasurementTests.cs | 161 ++++++++++++++++++
.../Authoring/EngineeringSnapshotBuilder.cs | 17 ++
.../Graph/TrackAuthoringGraphCompiler.cs | 15 ++
.../TrackAuthoringPipelineMeasurement.cs | 81 +++++++++
...ement-and-redundant-compilation-cleanup.md | 65 +++++++
7 files changed, 410 insertions(+)
create mode 100644 Quantum.Editor.Avalonia/Services/Viewport/EditorViewportPipelineMeasurement.cs
create mode 100644 Quantum.Tests/Editor/M167CompilationPipelineMeasurementTests.cs
create mode 100644 Quantum.Track/Authoring/TrackAuthoringPipelineMeasurement.cs
create mode 100644 docs/editor/m167-1-measurement-and-redundant-compilation-cleanup.md
diff --git a/Quantum.Editor.Avalonia/Services/Viewport/EditorViewportPipelineMeasurement.cs b/Quantum.Editor.Avalonia/Services/Viewport/EditorViewportPipelineMeasurement.cs
new file mode 100644
index 0000000..0a17863
--- /dev/null
+++ b/Quantum.Editor.Avalonia/Services/Viewport/EditorViewportPipelineMeasurement.cs
@@ -0,0 +1,56 @@
+using System.Threading;
+
+namespace Quantum.Editor.Avalonia.Services.Viewport;
+
+///
+/// Scoped, opt-in measurement for renderer-neutral editor viewport projection.
+///
+internal sealed class EditorViewportPipelineMeasurement : IDisposable
+{
+ private static readonly AsyncLocal CurrentValue = new();
+
+ private readonly EditorViewportPipelineMeasurement? previous;
+ private bool disposed;
+
+ private EditorViewportPipelineMeasurement()
+ {
+ previous = CurrentValue.Value;
+ CurrentValue.Value = this;
+ }
+
+ public int ViewportProjectionBuildCount { get; private set; }
+
+ public TimeSpan ViewportProjectionBuildElapsed { get; private set; }
+
+ public static EditorViewportPipelineMeasurement Begin()
+ {
+ return new EditorViewportPipelineMeasurement();
+ }
+
+ internal static void RecordViewportProjectionBuild(TimeSpan elapsed)
+ {
+ EditorViewportPipelineMeasurement? current = CurrentValue.Value;
+ if (current is null)
+ {
+ return;
+ }
+
+ current.ViewportProjectionBuildCount++;
+ current.ViewportProjectionBuildElapsed += elapsed;
+ }
+
+ public void Dispose()
+ {
+ if (disposed)
+ {
+ return;
+ }
+
+ if (ReferenceEquals(CurrentValue.Value, this))
+ {
+ CurrentValue.Value = previous;
+ }
+
+ disposed = true;
+ }
+}
diff --git a/Quantum.Editor.Avalonia/Services/Viewport/TrackSamplingService.cs b/Quantum.Editor.Avalonia/Services/Viewport/TrackSamplingService.cs
index 6ecf8cc..18a5e6e 100644
--- a/Quantum.Editor.Avalonia/Services/Viewport/TrackSamplingService.cs
+++ b/Quantum.Editor.Avalonia/Services/Viewport/TrackSamplingService.cs
@@ -1,6 +1,7 @@
using Quantum.Editor.Avalonia.Models;
using Quantum.Track;
using Quantum.Track.Authoring;
+using System.Diagnostics;
namespace Quantum.Editor.Avalonia.Services.Viewport;
@@ -24,6 +25,20 @@ public int GetSampleCount(double totalLength)
}
public TrackViewportSnapshot CreateViewportSnapshot(EngineeringSnapshot snapshot)
+ {
+ var stopwatch = Stopwatch.StartNew();
+ try
+ {
+ return CreateViewportSnapshotCore(snapshot);
+ }
+ finally
+ {
+ stopwatch.Stop();
+ EditorViewportPipelineMeasurement.RecordViewportProjectionBuild(stopwatch.Elapsed);
+ }
+ }
+
+ private static TrackViewportSnapshot CreateViewportSnapshotCore(EngineeringSnapshot snapshot)
{
ArgumentNullException.ThrowIfNull(snapshot);
if (snapshot.SampleCount == 0 || snapshot.TotalLength <= 0.0)
diff --git a/Quantum.Tests/Editor/M167CompilationPipelineMeasurementTests.cs b/Quantum.Tests/Editor/M167CompilationPipelineMeasurementTests.cs
new file mode 100644
index 0000000..05b54be
--- /dev/null
+++ b/Quantum.Tests/Editor/M167CompilationPipelineMeasurementTests.cs
@@ -0,0 +1,161 @@
+using System.Text;
+using Quantum.Editor.Avalonia.Services;
+using Quantum.Editor.Avalonia.Services.Documents;
+using Quantum.Editor.Avalonia.Services.Viewport;
+using Quantum.IO.TrackLayout.V2;
+using Quantum.Track.Authoring;
+
+namespace Quantum.Tests;
+
+public sealed class M167CompilationPipelineMeasurementTests
+{
+ [Fact]
+ public void Baseline_ApplyGraphEdit_CompilesFiveTimesAndBuildsOneProjection()
+ {
+ var workspace = new EditorWorkspace();
+ TrackEditorDocument document = EditorTestDocumentFactory.ActivateShowcase(workspace);
+ TrackAuthoringGraph graph = document.Graph!;
+ ConstantCurvatureSectionDefinition section = Assert.IsType(
+ graph.Nodes.Single(node => node.Id == "sweeper").Section);
+
+ using TrackAuthoringPipelineMeasurement authoring =
+ TrackAuthoringPipelineMeasurement.Begin();
+ using EditorViewportPipelineMeasurement viewport =
+ EditorViewportPipelineMeasurement.Begin();
+
+ bool applied = workspace.ApplyGraphEdit(
+ "Edit sweeper radius",
+ candidate => TrackAuthoringGraphOperations.Replace(
+ candidate,
+ section.Id,
+ new ConstantCurvatureSectionDefinition(
+ section.Id,
+ section.Length,
+ radius: 30.0,
+ section.RollRadians)));
+
+ Assert.True(applied);
+ Assert.Equal(5, authoring.GraphCompilerInvocationCount);
+ Assert.Equal(1, authoring.EngineeringSnapshotBuildCount);
+ Assert.Equal(1, viewport.ViewportProjectionBuildCount);
+ AssertPositiveTimings(authoring, viewport);
+ }
+
+ [Fact]
+ public void Baseline_Save_CompilesTwiceWithoutRebuildingProjection()
+ {
+ var workspace = new EditorWorkspace();
+ EditorTestDocumentFactory.ActivateShowcase(workspace);
+ string filePath = TemporaryFilePath();
+
+ try
+ {
+ using TrackAuthoringPipelineMeasurement authoring =
+ TrackAuthoringPipelineMeasurement.Begin();
+ using EditorViewportPipelineMeasurement viewport =
+ EditorViewportPipelineMeasurement.Begin();
+
+ workspace.SaveDocument(filePath);
+
+ Assert.Equal(2, authoring.GraphCompilerInvocationCount);
+ Assert.Equal(0, authoring.EngineeringSnapshotBuildCount);
+ Assert.Equal(0, viewport.ViewportProjectionBuildCount);
+ Assert.True(authoring.GraphCompilerElapsed > TimeSpan.Zero);
+ }
+ finally
+ {
+ File.Delete(filePath);
+ }
+ }
+
+ [Fact]
+ public void Baseline_Open_CompilesThreeTimesAndBuildsOneProjection()
+ {
+ string filePath = TemporaryFilePath();
+ File.WriteAllText(
+ filePath,
+ TrackLayoutPackageV2Json.Serialize(
+ TrackPackageFactory.CreateShowcasePackage(),
+ indented: true),
+ new UTF8Encoding(false));
+
+ try
+ {
+ var workspace = new EditorWorkspace();
+ using TrackAuthoringPipelineMeasurement authoring =
+ TrackAuthoringPipelineMeasurement.Begin();
+ using EditorViewportPipelineMeasurement viewport =
+ EditorViewportPipelineMeasurement.Begin();
+
+ workspace.OpenDocument(filePath);
+
+ Assert.Equal(3, authoring.GraphCompilerInvocationCount);
+ Assert.Equal(1, authoring.EngineeringSnapshotBuildCount);
+ Assert.Equal(1, viewport.ViewportProjectionBuildCount);
+ AssertPositiveTimings(authoring, viewport);
+ }
+ finally
+ {
+ File.Delete(filePath);
+ }
+ }
+
+ [Fact]
+ public void Baseline_UndoAndRedo_EachCompileTwiceAndBuildOneProjection()
+ {
+ var workspace = new EditorWorkspace();
+ TrackEditorDocument document = EditorTestDocumentFactory.ActivateShowcase(workspace);
+ ConstantCurvatureSectionDefinition section = Assert.IsType(
+ document.Graph!.Nodes.Single(node => node.Id == "sweeper").Section);
+ Assert.True(workspace.ApplyGraphEdit(
+ "Edit sweeper radius",
+ candidate => TrackAuthoringGraphOperations.Replace(
+ candidate,
+ section.Id,
+ new ConstantCurvatureSectionDefinition(
+ section.Id,
+ section.Length,
+ radius: 30.0,
+ section.RollRadians))));
+
+ AssertHistoryMeasurement(
+ workspace.UndoLast,
+ expectedCompilerInvocations: 2);
+ AssertHistoryMeasurement(
+ workspace.RedoLast,
+ expectedCompilerInvocations: 2);
+ }
+
+ private static void AssertHistoryMeasurement(
+ Func operation,
+ int expectedCompilerInvocations)
+ {
+ using TrackAuthoringPipelineMeasurement authoring =
+ TrackAuthoringPipelineMeasurement.Begin();
+ using EditorViewportPipelineMeasurement viewport =
+ EditorViewportPipelineMeasurement.Begin();
+
+ Assert.True(operation());
+
+ Assert.Equal(expectedCompilerInvocations, authoring.GraphCompilerInvocationCount);
+ Assert.Equal(1, authoring.EngineeringSnapshotBuildCount);
+ Assert.Equal(1, viewport.ViewportProjectionBuildCount);
+ AssertPositiveTimings(authoring, viewport);
+ }
+
+ private static void AssertPositiveTimings(
+ TrackAuthoringPipelineMeasurement authoring,
+ EditorViewportPipelineMeasurement viewport)
+ {
+ Assert.True(authoring.GraphCompilerElapsed > TimeSpan.Zero);
+ Assert.True(authoring.EngineeringSnapshotBuildElapsed > TimeSpan.Zero);
+ Assert.True(viewport.ViewportProjectionBuildElapsed > TimeSpan.Zero);
+ }
+
+ private static string TemporaryFilePath()
+ {
+ return Path.Combine(
+ Path.GetTempPath(),
+ $"quantum-m167-{Guid.NewGuid():N}.track-layout-v2.json");
+ }
+}
diff --git a/Quantum.Track/Authoring/EngineeringSnapshotBuilder.cs b/Quantum.Track/Authoring/EngineeringSnapshotBuilder.cs
index 8159264..f7a3829 100644
--- a/Quantum.Track/Authoring/EngineeringSnapshotBuilder.cs
+++ b/Quantum.Track/Authoring/EngineeringSnapshotBuilder.cs
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
+using System.Diagnostics;
namespace Quantum.Track.Authoring
{
@@ -11,6 +12,22 @@ public static class EngineeringSnapshotBuilder
public static EngineeringSnapshot Build(
TrackAuthoringCompilation compilation,
EngineeringSnapshotRequest request)
+ {
+ var stopwatch = Stopwatch.StartNew();
+ try
+ {
+ return BuildCore(compilation, request);
+ }
+ finally
+ {
+ stopwatch.Stop();
+ TrackAuthoringPipelineMeasurement.RecordEngineeringSnapshotBuild(stopwatch.Elapsed);
+ }
+ }
+
+ private static EngineeringSnapshot BuildCore(
+ TrackAuthoringCompilation compilation,
+ EngineeringSnapshotRequest request)
{
if (compilation is null)
{
diff --git a/Quantum.Track/Authoring/Graph/TrackAuthoringGraphCompiler.cs b/Quantum.Track/Authoring/Graph/TrackAuthoringGraphCompiler.cs
index 6f6eee1..36beb45 100644
--- a/Quantum.Track/Authoring/Graph/TrackAuthoringGraphCompiler.cs
+++ b/Quantum.Track/Authoring/Graph/TrackAuthoringGraphCompiler.cs
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
+using System.Diagnostics;
using System.Linq;
namespace Quantum.Track.Authoring
@@ -11,6 +12,20 @@ namespace Quantum.Track.Authoring
public static class TrackAuthoringGraphCompiler
{
public static TrackAuthoringGraphCompileResult Compile(TrackAuthoringGraph graph)
+ {
+ var stopwatch = Stopwatch.StartNew();
+ try
+ {
+ return CompileCore(graph);
+ }
+ finally
+ {
+ stopwatch.Stop();
+ TrackAuthoringPipelineMeasurement.RecordGraphCompilation(stopwatch.Elapsed);
+ }
+ }
+
+ private static TrackAuthoringGraphCompileResult CompileCore(TrackAuthoringGraph graph)
{
if (graph is null)
{
diff --git a/Quantum.Track/Authoring/TrackAuthoringPipelineMeasurement.cs b/Quantum.Track/Authoring/TrackAuthoringPipelineMeasurement.cs
new file mode 100644
index 0000000..9d27c9d
--- /dev/null
+++ b/Quantum.Track/Authoring/TrackAuthoringPipelineMeasurement.cs
@@ -0,0 +1,81 @@
+using System;
+using System.Threading;
+
+namespace Quantum.Track.Authoring
+{
+ ///
+ /// Scoped, opt-in measurement for the synchronous authoring pipeline.
+ ///
+ ///
+ /// This internal hook keeps production APIs unchanged while allowing editor
+ /// integration tests and diagnostic harnesses to observe otherwise hidden
+ /// compiler and projection work. Measurements flow with the current async
+ /// context and do not interfere with measurements in parallel tests.
+ ///
+ internal sealed class TrackAuthoringPipelineMeasurement : IDisposable
+ {
+ private static readonly AsyncLocal CurrentValue =
+ new AsyncLocal();
+
+ private readonly TrackAuthoringPipelineMeasurement? _previous;
+ private bool _disposed;
+
+ private TrackAuthoringPipelineMeasurement()
+ {
+ _previous = CurrentValue.Value;
+ CurrentValue.Value = this;
+ }
+
+ public int GraphCompilerInvocationCount { get; private set; }
+
+ public TimeSpan GraphCompilerElapsed { get; private set; }
+
+ public int EngineeringSnapshotBuildCount { get; private set; }
+
+ public TimeSpan EngineeringSnapshotBuildElapsed { get; private set; }
+
+ public static TrackAuthoringPipelineMeasurement Begin()
+ {
+ return new TrackAuthoringPipelineMeasurement();
+ }
+
+ internal static void RecordGraphCompilation(TimeSpan elapsed)
+ {
+ TrackAuthoringPipelineMeasurement? current = CurrentValue.Value;
+ if (current is null)
+ {
+ return;
+ }
+
+ current.GraphCompilerInvocationCount++;
+ current.GraphCompilerElapsed += elapsed;
+ }
+
+ internal static void RecordEngineeringSnapshotBuild(TimeSpan elapsed)
+ {
+ TrackAuthoringPipelineMeasurement? current = CurrentValue.Value;
+ if (current is null)
+ {
+ return;
+ }
+
+ current.EngineeringSnapshotBuildCount++;
+ current.EngineeringSnapshotBuildElapsed += elapsed;
+ }
+
+ public void Dispose()
+ {
+ if (_disposed)
+ {
+ return;
+ }
+
+ if (ReferenceEquals(CurrentValue.Value, this))
+ {
+ CurrentValue.Value = _previous;
+ }
+
+ _disposed = true;
+ }
+ }
+}
diff --git a/docs/editor/m167-1-measurement-and-redundant-compilation-cleanup.md b/docs/editor/m167-1-measurement-and-redundant-compilation-cleanup.md
new file mode 100644
index 0000000..9438020
--- /dev/null
+++ b/docs/editor/m167-1-measurement-and-redundant-compilation-cleanup.md
@@ -0,0 +1,65 @@
+# M167.1 Measurement and Redundant Compilation Cleanup
+
+## Scope
+
+M167.1 measures and reduces redundant synchronous authoring work without adding
+transactions, scheduling, background work, or interactive editing. Track Layout
+Package V2 and the visible M166 editor workflow remain unchanged.
+
+## Measurement hooks
+
+The measurement hooks are internal and opt-in so the public backend and editor
+contracts do not change:
+
+- `TrackAuthoringPipelineMeasurement` counts graph compiler invocations and records
+ total graph-compilation and `EngineeringSnapshot` build time within one scoped
+ execution context.
+- `EditorViewportPipelineMeasurement` counts renderer-neutral viewport projection
+ builds and records their total time within one scoped execution context.
+
+The scopes use `AsyncLocal` ownership. Parallel tests can measure independent editor
+operations without resetting global process counters.
+
+## Confirmed baseline
+
+The baseline counts below are asserted by
+`M167CompilationPipelineMeasurementTests` before the cleanup:
+
+| Operation | Graph compiler invocations | Engineering snapshots | Viewport projections |
+|---|---:|---:|---:|
+| ApplyGraphEdit, non-empty changed route | 5 | 1 | 1 |
+| ReplaceGraph, non-empty route | 2 | 1 when active | 1 when active |
+| Save | 2 | 0 | 0 |
+| Open | 3 | 1 | 1 |
+| Undo to non-empty route | 2 | 1 | 1 |
+| Redo to non-empty route | 2 | 1 | 1 |
+
+For `ApplyGraphEdit`, the five full compilations are:
+
+1. candidate preflight in `EditorWorkspace.ApplyGraphEdit`;
+2. current-graph compilation hidden inside V2 export for `beforeJson`;
+3. candidate compilation hidden inside V2 export for `afterJson`;
+4. candidate compilation in `TrackEditorDocument.ReplaceGraph`;
+5. candidate compilation hidden inside V2 export for dirty-state comparison.
+
+Only the fourth candidate compilation becomes the active document compilation and
+feeds the engineering and viewport projection. The other four results are discarded.
+
+## Cleanup target
+
+M167.1 will retain the already validated graph compilation through document
+replacement and history, provide a V2 export path for an existing successful compile
+result, and cache canonical committed package JSON. The target counts are:
+
+| Operation | Target graph compiler invocations |
+|---|---:|
+| ApplyGraphEdit, non-empty changed route | 1 |
+| ReplaceGraph, non-empty route | 1 |
+| Save | 0 |
+| Open | 1 |
+| Undo | 0 |
+| Redo | 0 |
+
+Engineering snapshots and viewport projections remain one per changed active
+compilation. No rendering, selection, dirty-state, persistence, or history behavior is
+intended to change.
From 2d4a9a1bdfccc7e09c73f42e7adc71001410e80d Mon Sep 17 00:00:00 2001
From: Coasterpete <83011124+Coasterpete@users.noreply.github.com>
Date: Wed, 22 Jul 2026 14:57:41 -0400
Subject: [PATCH 02/11] M167.1: reuse compiled editor graph states
---
.../Services/Documents/TrackEditorDocument.cs | 106 +++++++++++++++---
.../Documents/TrackEditorGraphState.cs | 47 ++++++++
.../Services/EditorWorkspace.cs | 25 +++--
.../UndoRedo/TrackGraphSnapshotOperation.cs | 42 ++++++-
.../V2/TrackLayoutPackageV2GraphAdapter.cs | 34 ++++++
...M167CompilationPipelineMeasurementTests.cs | 61 ++++++++--
.../TrackLayoutPackageV2GraphAdapterTests.cs | 48 ++++++++
.../Graph/TrackAuthoringGraphCompileResult.cs | 12 ++
.../Graph/TrackAuthoringGraphCompiler.cs | 12 +-
...ement-and-redundant-compilation-cleanup.md | 81 ++++++++++---
10 files changed, 405 insertions(+), 63 deletions(-)
create mode 100644 Quantum.Editor.Avalonia/Services/Documents/TrackEditorGraphState.cs
diff --git a/Quantum.Editor.Avalonia/Services/Documents/TrackEditorDocument.cs b/Quantum.Editor.Avalonia/Services/Documents/TrackEditorDocument.cs
index 65d78e1..a6e8a41 100644
--- a/Quantum.Editor.Avalonia/Services/Documents/TrackEditorDocument.cs
+++ b/Quantum.Editor.Avalonia/Services/Documents/TrackEditorDocument.cs
@@ -8,6 +8,7 @@ public sealed class TrackEditorDocument : IEditorDocument
{
private TrackAuthoringGraph? graph;
private TrackLayoutPackageV2GraphAncillaryState? ancillaryState;
+ private string? currentPackageJson;
private string? cleanPackageJson;
private bool explicitlyDirty;
@@ -32,7 +33,10 @@ private TrackEditorDocument(
GraphCompileResult = graphCompileResult;
Compilation = graphCompileResult?.Compilation;
FilePath = filePath;
- cleanPackageJson = CanSave ? CapturePackageJson() : null;
+ currentPackageJson = CanSave
+ ? SerializePackage(graph, ancillaryState, graphCompileResult!)
+ : null;
+ cleanPackageJson = currentPackageJson;
}
public event EventHandler? Changed;
@@ -60,7 +64,7 @@ private TrackEditorDocument(
public TrackLayoutPackageV2Dto? Package =>
!CanSave
? null
- : ExportPackage(graph!, ancillaryState!);
+ : ExportPackage(graph!, ancillaryState!, GraphCompileResult!);
public TrackAuthoringCompilation? Compilation { get; private set; }
@@ -137,10 +141,23 @@ public string CapturePackageJson()
"A non-empty successfully compiled route is required before saving Track Layout Package V2.");
}
- return SerializePackage(graph!, ancillaryState!);
+ return currentPackageJson!;
}
- internal string CapturePackageJson(TrackAuthoringGraph candidateGraph)
+ internal TrackEditorGraphState CaptureGraphState()
+ {
+ if (graph is null)
+ {
+ throw new InvalidOperationException(
+ "This editor document does not have an editable authoring graph.");
+ }
+
+ return new TrackEditorGraphState(graph, GraphCompileResult, currentPackageJson);
+ }
+
+ internal TrackEditorGraphState PrepareGraphState(
+ TrackAuthoringGraph candidateGraph,
+ TrackAuthoringGraphCompileResult? candidateCompilation)
{
ArgumentNullException.ThrowIfNull(candidateGraph);
if (ancillaryState is null)
@@ -149,7 +166,28 @@ internal string CapturePackageJson(TrackAuthoringGraph candidateGraph)
"This editor document does not have Track Layout Package V2 ancillary state.");
}
- return SerializePackage(candidateGraph, ancillaryState);
+ if (candidateGraph.Nodes.Count == 0)
+ {
+ return new TrackEditorGraphState(candidateGraph, null, null);
+ }
+
+ if (candidateCompilation is null ||
+ !candidateCompilation.Success ||
+ candidateCompilation.Compilation is null)
+ {
+ throw new ArgumentException(
+ "A non-empty graph requires a successful compilation.",
+ nameof(candidateCompilation));
+ }
+
+ string packageJson = SerializePackage(
+ candidateGraph,
+ ancillaryState,
+ candidateCompilation);
+ return new TrackEditorGraphState(
+ candidateGraph,
+ candidateCompilation,
+ packageJson);
}
public void ReplaceGraph(TrackAuthoringGraph candidateGraph)
@@ -172,8 +210,7 @@ public void ReplaceGraph(TrackAuthoringGraph candidateGraph)
if (candidateGraph.Nodes.Count == 0)
{
- bool emptyIsDirty = explicitlyDirty || cleanPackageJson != null;
- CommitEmptyGraph(candidateGraph, emptyIsDirty);
+ ReplaceGraphState(PrepareGraphState(candidateGraph, null));
return;
}
@@ -186,11 +223,34 @@ public void ReplaceGraph(TrackAuthoringGraph candidateGraph)
candidateCompilation.Diagnostics));
}
- string candidatePackageJson = CapturePackageJson(candidateGraph);
+ ReplaceGraphState(PrepareGraphState(candidateGraph, candidateCompilation));
+ }
+
+ internal void ReplaceGraphState(TrackEditorGraphState candidateState)
+ {
+ ArgumentNullException.ThrowIfNull(candidateState);
+ if (graph is null || ancillaryState is null)
+ {
+ throw new InvalidOperationException(
+ "This editor document does not have an editable authoring graph.");
+ }
+
bool candidateIsDirty = explicitlyDirty ||
- cleanPackageJson is null ||
- !string.Equals(cleanPackageJson, candidatePackageJson, StringComparison.Ordinal);
- CommitCompiledGraph(candidateGraph, candidateCompilation, candidateIsDirty);
+ !string.Equals(
+ cleanPackageJson,
+ candidateState.PackageJson,
+ StringComparison.Ordinal);
+ if (candidateState.GraphCompileResult is null)
+ {
+ CommitEmptyGraph(candidateState.Graph, candidateIsDirty);
+ return;
+ }
+
+ CommitCompiledGraph(
+ candidateState.Graph,
+ candidateState.GraphCompileResult,
+ candidateState.PackageJson!,
+ candidateIsDirty);
}
public void ReplacePackageJson(string json, bool markDirty = true)
@@ -216,9 +276,13 @@ public void ReplacePackageJson(string json, bool markDirty = true)
import.Diagnostics);
}
- string replacementPackageJson = SerializePackage(import.Graph, import.AncillaryState);
+ string replacementPackageJson = SerializePackage(
+ import.Graph,
+ import.AncillaryState,
+ candidateCompilation);
graph = import.Graph;
ancillaryState = import.AncillaryState;
+ currentPackageJson = replacementPackageJson;
GraphCompileResult = candidateCompilation;
Compilation = candidateCompilation.Compilation;
TrackDocument = candidateCompilation.Compilation.Document;
@@ -262,7 +326,7 @@ public void MarkDirty()
public void MarkClean()
{
explicitlyDirty = false;
- cleanPackageJson = CanSave ? CapturePackageJson() : null;
+ cleanPackageJson = currentPackageJson;
if (!IsDirty)
{
return;
@@ -275,9 +339,11 @@ public void MarkClean()
private void CommitCompiledGraph(
TrackAuthoringGraph candidateGraph,
TrackAuthoringGraphCompileResult candidateCompilation,
+ string candidatePackageJson,
bool candidateIsDirty)
{
graph = candidateGraph;
+ currentPackageJson = candidatePackageJson;
GraphCompileResult = candidateCompilation;
Compilation = candidateCompilation.Compilation;
TrackDocument = candidateCompilation.Compilation!.Document;
@@ -290,6 +356,7 @@ private void CommitEmptyGraph(
bool candidateIsDirty)
{
graph = candidateGraph;
+ currentPackageJson = null;
GraphCompileResult = null;
Compilation = null;
TrackDocument = new TrackDocument();
@@ -299,19 +366,24 @@ private void CommitEmptyGraph(
private static string SerializePackage(
TrackAuthoringGraph sourceGraph,
- TrackLayoutPackageV2GraphAncillaryState sourceAncillaryState)
+ TrackLayoutPackageV2GraphAncillaryState sourceAncillaryState,
+ TrackAuthoringGraphCompileResult graphCompilation)
{
return TrackLayoutPackageV2Json.Serialize(
- ExportPackage(sourceGraph, sourceAncillaryState),
+ ExportPackage(sourceGraph, sourceAncillaryState, graphCompilation),
indented: true);
}
private static TrackLayoutPackageV2Dto ExportPackage(
TrackAuthoringGraph sourceGraph,
- TrackLayoutPackageV2GraphAncillaryState sourceAncillaryState)
+ TrackLayoutPackageV2GraphAncillaryState sourceAncillaryState,
+ TrackAuthoringGraphCompileResult graphCompilation)
{
TrackLayoutPackageV2GraphExportResult export =
- TrackLayoutPackageV2GraphAdapter.Export(sourceGraph, sourceAncillaryState);
+ TrackLayoutPackageV2GraphAdapter.Export(
+ sourceGraph,
+ sourceAncillaryState,
+ graphCompilation);
if (!export.Success || export.Package is null)
{
if (export.GraphDiagnostics.Count != 0)
diff --git a/Quantum.Editor.Avalonia/Services/Documents/TrackEditorGraphState.cs b/Quantum.Editor.Avalonia/Services/Documents/TrackEditorGraphState.cs
new file mode 100644
index 0000000..7564289
--- /dev/null
+++ b/Quantum.Editor.Avalonia/Services/Documents/TrackEditorGraphState.cs
@@ -0,0 +1,47 @@
+using Quantum.Track.Authoring;
+
+namespace Quantum.Editor.Avalonia.Services.Documents;
+
+///
+/// One immutable graph snapshot together with the exact successful compilation and
+/// canonical package JSON derived from it. Empty graphs intentionally have neither.
+///
+internal sealed class TrackEditorGraphState
+{
+ public TrackEditorGraphState(
+ TrackAuthoringGraph graph,
+ TrackAuthoringGraphCompileResult? graphCompileResult,
+ string? packageJson)
+ {
+ Graph = graph ?? throw new ArgumentNullException(nameof(graph));
+
+ if (graph.Nodes.Count == 0)
+ {
+ if (graphCompileResult is not null || packageJson is not null)
+ {
+ throw new ArgumentException(
+ "An empty graph cannot have a compilation or package snapshot.",
+ nameof(graphCompileResult));
+ }
+ }
+ else if (graphCompileResult is null ||
+ !graphCompileResult.Success ||
+ graphCompileResult.Compilation is null ||
+ !ReferenceEquals(graphCompileResult.SourceGraph, graph) ||
+ packageJson is null)
+ {
+ throw new ArgumentException(
+ "A non-empty graph state requires its exact successful compilation and package snapshot.",
+ nameof(graphCompileResult));
+ }
+
+ GraphCompileResult = graphCompileResult;
+ PackageJson = packageJson;
+ }
+
+ public TrackAuthoringGraph Graph { get; }
+
+ public TrackAuthoringGraphCompileResult? GraphCompileResult { get; }
+
+ public string? PackageJson { get; }
+}
diff --git a/Quantum.Editor.Avalonia/Services/EditorWorkspace.cs b/Quantum.Editor.Avalonia/Services/EditorWorkspace.cs
index f45da33..a3c39e4 100644
--- a/Quantum.Editor.Avalonia/Services/EditorWorkspace.cs
+++ b/Quantum.Editor.Avalonia/Services/EditorWorkspace.cs
@@ -135,13 +135,15 @@ public bool ApplyGraphEdit(
ArgumentNullException.ThrowIfNull(edit);
TrackEditorDocument? document = ActiveDocument;
- TrackAuthoringGraph? beforeGraph = document?.Graph;
- if (document is null || beforeGraph is null)
+ if (document?.Graph is null)
{
SetStatus("The active document does not have an editable authoring graph.");
return false;
}
+ TrackEditorGraphState beforeState = document.CaptureGraphState();
+ TrackAuthoringGraph beforeGraph = beforeState.Graph;
+
TrackAuthoringGraph candidateGraph;
try
{
@@ -171,9 +173,10 @@ exception is InvalidOperationException ||
return false;
}
+ TrackAuthoringGraphCompileResult? candidateCompilation = null;
if (candidateGraph.Nodes.Count != 0)
{
- TrackAuthoringGraphCompileResult candidateCompilation =
+ candidateCompilation =
TrackAuthoringGraphCompiler.Compile(candidateGraph);
if (!candidateCompilation.Success || candidateCompilation.Compilation is null)
{
@@ -184,11 +187,15 @@ exception is InvalidOperationException ||
try
{
- if (beforeGraph.Nodes.Count != 0 && candidateGraph.Nodes.Count != 0)
+ TrackEditorGraphState afterState = document.PrepareGraphState(
+ candidateGraph,
+ candidateCompilation);
+ if (beforeState.PackageJson is not null && afterState.PackageJson is not null)
{
- string beforeJson = document.CapturePackageJson();
- string afterJson = document.CapturePackageJson(candidateGraph);
- if (string.Equals(beforeJson, afterJson, StringComparison.Ordinal))
+ if (string.Equals(
+ beforeState.PackageJson,
+ afterState.PackageJson,
+ StringComparison.Ordinal))
{
SetStatus("No graph values changed.");
return false;
@@ -198,8 +205,8 @@ exception is InvalidOperationException ||
UndoRedo.Execute(new TrackGraphSnapshotOperation(
description,
document,
- beforeGraph,
- candidateGraph));
+ beforeState,
+ afterState));
SetStatus(description + ".");
return true;
}
diff --git a/Quantum.Editor.Avalonia/Services/UndoRedo/TrackGraphSnapshotOperation.cs b/Quantum.Editor.Avalonia/Services/UndoRedo/TrackGraphSnapshotOperation.cs
index 35365dc..47d6af8 100644
--- a/Quantum.Editor.Avalonia/Services/UndoRedo/TrackGraphSnapshotOperation.cs
+++ b/Quantum.Editor.Avalonia/Services/UndoRedo/TrackGraphSnapshotOperation.cs
@@ -9,8 +9,10 @@ namespace Quantum.Editor.Avalonia.Services.UndoRedo;
public sealed class TrackGraphSnapshotOperation : IUndoableEditorOperation
{
private readonly TrackEditorDocument document;
- private readonly TrackAuthoringGraph beforeGraph;
- private readonly TrackAuthoringGraph afterGraph;
+ private readonly TrackAuthoringGraph? beforeGraph;
+ private readonly TrackAuthoringGraph? afterGraph;
+ private readonly TrackEditorGraphState? beforeState;
+ private readonly TrackEditorGraphState? afterState;
public TrackGraphSnapshotOperation(
string description,
@@ -26,9 +28,41 @@ public TrackGraphSnapshotOperation(
this.afterGraph = afterGraph ?? throw new ArgumentNullException(nameof(afterGraph));
}
+ internal TrackGraphSnapshotOperation(
+ string description,
+ TrackEditorDocument document,
+ TrackEditorGraphState beforeState,
+ TrackEditorGraphState afterState)
+ {
+ Description = string.IsNullOrWhiteSpace(description)
+ ? throw new ArgumentException("Operation description is required.", nameof(description))
+ : description;
+ this.document = document ?? throw new ArgumentNullException(nameof(document));
+ this.beforeState = beforeState ?? throw new ArgumentNullException(nameof(beforeState));
+ this.afterState = afterState ?? throw new ArgumentNullException(nameof(afterState));
+ }
+
public string Description { get; }
- public void Execute() => document.ReplaceGraph(afterGraph);
+ public void Execute()
+ {
+ if (afterState is not null)
+ {
+ document.ReplaceGraphState(afterState);
+ return;
+ }
+
+ document.ReplaceGraph(afterGraph!);
+ }
- public void Undo() => document.ReplaceGraph(beforeGraph);
+ public void Undo()
+ {
+ if (beforeState is not null)
+ {
+ document.ReplaceGraphState(beforeState);
+ return;
+ }
+
+ document.ReplaceGraph(beforeGraph!);
+ }
}
diff --git a/Quantum.IO/TrackLayout/V2/TrackLayoutPackageV2GraphAdapter.cs b/Quantum.IO/TrackLayout/V2/TrackLayoutPackageV2GraphAdapter.cs
index f661611..c0ebfac 100644
--- a/Quantum.IO/TrackLayout/V2/TrackLayoutPackageV2GraphAdapter.cs
+++ b/Quantum.IO/TrackLayout/V2/TrackLayoutPackageV2GraphAdapter.cs
@@ -165,6 +165,40 @@ public static TrackLayoutPackageV2GraphExportResult Export(
TrackAuthoringGraphCompileResult graphCompilation =
TrackAuthoringGraphCompiler.Compile(graph);
+ return Export(graph, ancillaryState, graphCompilation);
+ }
+
+ ///
+ /// Exports a graph using the successful compilation produced for that exact
+ /// immutable graph instance.
+ ///
+ public static TrackLayoutPackageV2GraphExportResult Export(
+ TrackAuthoringGraph graph,
+ TrackLayoutPackageV2GraphAncillaryState ancillaryState,
+ TrackAuthoringGraphCompileResult graphCompilation)
+ {
+ if (graph is null)
+ {
+ throw new ArgumentNullException(nameof(graph));
+ }
+
+ if (ancillaryState is null)
+ {
+ throw new ArgumentNullException(nameof(ancillaryState));
+ }
+
+ if (graphCompilation is null)
+ {
+ throw new ArgumentNullException(nameof(graphCompilation));
+ }
+
+ if (!ReferenceEquals(graphCompilation.SourceGraph, graph))
+ {
+ throw new ArgumentException(
+ "The supplied compilation was not produced for the graph being exported.",
+ nameof(graphCompilation));
+ }
+
if (!graphCompilation.Success)
{
return new TrackLayoutPackageV2GraphExportResult(
diff --git a/Quantum.Tests/Editor/M167CompilationPipelineMeasurementTests.cs b/Quantum.Tests/Editor/M167CompilationPipelineMeasurementTests.cs
index 05b54be..622e077 100644
--- a/Quantum.Tests/Editor/M167CompilationPipelineMeasurementTests.cs
+++ b/Quantum.Tests/Editor/M167CompilationPipelineMeasurementTests.cs
@@ -10,7 +10,7 @@ namespace Quantum.Tests;
public sealed class M167CompilationPipelineMeasurementTests
{
[Fact]
- public void Baseline_ApplyGraphEdit_CompilesFiveTimesAndBuildsOneProjection()
+ public void ApplyGraphEdit_CompilesOnceAndBuildsOneProjection()
{
var workspace = new EditorWorkspace();
TrackEditorDocument document = EditorTestDocumentFactory.ActivateShowcase(workspace);
@@ -35,14 +35,15 @@ public void Baseline_ApplyGraphEdit_CompilesFiveTimesAndBuildsOneProjection()
section.RollRadians)));
Assert.True(applied);
- Assert.Equal(5, authoring.GraphCompilerInvocationCount);
+ Assert.Equal(1, authoring.GraphCompilerInvocationCount);
Assert.Equal(1, authoring.EngineeringSnapshotBuildCount);
Assert.Equal(1, viewport.ViewportProjectionBuildCount);
+ Assert.Same(document.Graph, document.GraphCompileResult!.SourceGraph);
AssertPositiveTimings(authoring, viewport);
}
[Fact]
- public void Baseline_Save_CompilesTwiceWithoutRebuildingProjection()
+ public void Save_DoesNotCompileOrRebuildProjection()
{
var workspace = new EditorWorkspace();
EditorTestDocumentFactory.ActivateShowcase(workspace);
@@ -57,10 +58,12 @@ public void Baseline_Save_CompilesTwiceWithoutRebuildingProjection()
workspace.SaveDocument(filePath);
- Assert.Equal(2, authoring.GraphCompilerInvocationCount);
+ Assert.Equal(0, authoring.GraphCompilerInvocationCount);
Assert.Equal(0, authoring.EngineeringSnapshotBuildCount);
Assert.Equal(0, viewport.ViewportProjectionBuildCount);
- Assert.True(authoring.GraphCompilerElapsed > TimeSpan.Zero);
+ Assert.Equal(TimeSpan.Zero, authoring.GraphCompilerElapsed);
+ Assert.Equal(TimeSpan.Zero, authoring.EngineeringSnapshotBuildElapsed);
+ Assert.Equal(TimeSpan.Zero, viewport.ViewportProjectionBuildElapsed);
}
finally
{
@@ -69,7 +72,7 @@ public void Baseline_Save_CompilesTwiceWithoutRebuildingProjection()
}
[Fact]
- public void Baseline_Open_CompilesThreeTimesAndBuildsOneProjection()
+ public void Open_CompilesOnceAndBuildsOneProjection()
{
string filePath = TemporaryFilePath();
File.WriteAllText(
@@ -89,7 +92,7 @@ public void Baseline_Open_CompilesThreeTimesAndBuildsOneProjection()
workspace.OpenDocument(filePath);
- Assert.Equal(3, authoring.GraphCompilerInvocationCount);
+ Assert.Equal(1, authoring.GraphCompilerInvocationCount);
Assert.Equal(1, authoring.EngineeringSnapshotBuildCount);
Assert.Equal(1, viewport.ViewportProjectionBuildCount);
AssertPositiveTimings(authoring, viewport);
@@ -101,10 +104,11 @@ public void Baseline_Open_CompilesThreeTimesAndBuildsOneProjection()
}
[Fact]
- public void Baseline_UndoAndRedo_EachCompileTwiceAndBuildOneProjection()
+ public void UndoAndRedo_ReusePreparedStatesAndBuildOneProjection()
{
var workspace = new EditorWorkspace();
TrackEditorDocument document = EditorTestDocumentFactory.ActivateShowcase(workspace);
+ TrackAuthoringCompilation beforeCompilation = document.Compilation!;
ConstantCurvatureSectionDefinition section = Assert.IsType(
document.Graph!.Nodes.Single(node => node.Id == "sweeper").Section);
Assert.True(workspace.ApplyGraphEdit(
@@ -117,13 +121,46 @@ public void Baseline_UndoAndRedo_EachCompileTwiceAndBuildOneProjection()
section.Length,
radius: 30.0,
section.RollRadians))));
+ TrackAuthoringCompilation afterCompilation = document.Compilation!;
AssertHistoryMeasurement(
workspace.UndoLast,
- expectedCompilerInvocations: 2);
+ expectedCompilerInvocations: 0);
+ Assert.Same(beforeCompilation, document.Compilation);
AssertHistoryMeasurement(
workspace.RedoLast,
- expectedCompilerInvocations: 2);
+ expectedCompilerInvocations: 0);
+ Assert.Same(afterCompilation, document.Compilation);
+ }
+
+ [Fact]
+ public void ReplaceGraph_CompilesOnceAndBuildsOneProjection()
+ {
+ var workspace = new EditorWorkspace();
+ TrackEditorDocument document = EditorTestDocumentFactory.ActivateShowcase(workspace);
+ ConstantCurvatureSectionDefinition section = Assert.IsType(
+ document.Graph!.Nodes.Single(node => node.Id == "sweeper").Section);
+ TrackAuthoringGraph replacement = TrackAuthoringGraphOperations.Replace(
+ document.Graph,
+ section.Id,
+ new ConstantCurvatureSectionDefinition(
+ section.Id,
+ section.Length,
+ radius: 30.0,
+ section.RollRadians));
+
+ using TrackAuthoringPipelineMeasurement authoring =
+ TrackAuthoringPipelineMeasurement.Begin();
+ using EditorViewportPipelineMeasurement viewport =
+ EditorViewportPipelineMeasurement.Begin();
+
+ document.ReplaceGraph(replacement);
+
+ Assert.Equal(1, authoring.GraphCompilerInvocationCount);
+ Assert.Equal(1, authoring.EngineeringSnapshotBuildCount);
+ Assert.Equal(1, viewport.ViewportProjectionBuildCount);
+ Assert.Same(replacement, document.GraphCompileResult!.SourceGraph);
+ AssertPositiveTimings(authoring, viewport);
}
private static void AssertHistoryMeasurement(
@@ -140,7 +177,9 @@ private static void AssertHistoryMeasurement(
Assert.Equal(expectedCompilerInvocations, authoring.GraphCompilerInvocationCount);
Assert.Equal(1, authoring.EngineeringSnapshotBuildCount);
Assert.Equal(1, viewport.ViewportProjectionBuildCount);
- AssertPositiveTimings(authoring, viewport);
+ Assert.Equal(TimeSpan.Zero, authoring.GraphCompilerElapsed);
+ Assert.True(authoring.EngineeringSnapshotBuildElapsed > TimeSpan.Zero);
+ Assert.True(viewport.ViewportProjectionBuildElapsed > TimeSpan.Zero);
}
private static void AssertPositiveTimings(
diff --git a/Quantum.Tests/IO/TrackLayoutPackageV2GraphAdapterTests.cs b/Quantum.Tests/IO/TrackLayoutPackageV2GraphAdapterTests.cs
index fd1015f..5ec131f 100644
--- a/Quantum.Tests/IO/TrackLayoutPackageV2GraphAdapterTests.cs
+++ b/Quantum.Tests/IO/TrackLayoutPackageV2GraphAdapterTests.cs
@@ -60,6 +60,54 @@ public void ImportThenExport_RepresentativePackagePreservesCanonicalV2Json()
Assert.Equal(before, after);
}
+ [Fact]
+ public void Export_WithExistingCompilationPreservesCanonicalV2JsonWithoutRecompiling()
+ {
+ TrackLayoutPackageV2Dto dto = CreateRepresentativeDto();
+ TrackLayoutPackageV2GraphImportResult import = TrackLayoutPackageV2GraphAdapter.Import(dto);
+ TrackAuthoringGraphCompileResult compilation =
+ TrackAuthoringGraphCompiler.Compile(import.Graph!);
+ string expected = TrackLayoutPackageV2Json.Serialize(dto, indented: true);
+
+ using TrackAuthoringPipelineMeasurement measurement =
+ TrackAuthoringPipelineMeasurement.Begin();
+
+ TrackLayoutPackageV2GraphExportResult export = TrackLayoutPackageV2GraphAdapter.Export(
+ import.Graph!,
+ import.AncillaryState!,
+ compilation);
+
+ Assert.True(export.Success);
+ Assert.Equal(0, measurement.GraphCompilerInvocationCount);
+ Assert.Equal(
+ expected,
+ TrackLayoutPackageV2Json.Serialize(export.Package!, indented: true));
+ }
+
+ [Fact]
+ public void Export_WithCompilationForAnotherGraphIsRejected()
+ {
+ TrackLayoutPackageV2GraphImportResult import = TrackLayoutPackageV2GraphAdapter.Import(
+ CreateRepresentativeDto());
+ TrackAuthoringGraphCompileResult compilation =
+ TrackAuthoringGraphCompiler.Compile(import.Graph!);
+ TrackAuthoringGraph changed = import.Graph!.WithSection(
+ "turn",
+ new ConstantCurvatureSectionDefinition(
+ "turn",
+ length: 12.0,
+ radius: -45.0,
+ rollRadians: -0.2));
+
+ ArgumentException exception = Assert.Throws(() =>
+ TrackLayoutPackageV2GraphAdapter.Export(
+ changed,
+ import.AncillaryState!,
+ compilation));
+
+ Assert.Equal("graphCompilation", exception.ParamName);
+ }
+
[Fact]
public void Export_UsesGraphRouteAndReplacementSectionAsOnlySectionSource()
{
diff --git a/Quantum.Track/Authoring/Graph/TrackAuthoringGraphCompileResult.cs b/Quantum.Track/Authoring/Graph/TrackAuthoringGraphCompileResult.cs
index 64c2ad0..f445cc0 100644
--- a/Quantum.Track/Authoring/Graph/TrackAuthoringGraphCompileResult.cs
+++ b/Quantum.Track/Authoring/Graph/TrackAuthoringGraphCompileResult.cs
@@ -12,12 +12,18 @@ public sealed class TrackAuthoringGraphCompileResult
private readonly IReadOnlyList _diagnostics;
internal TrackAuthoringGraphCompileResult(
+ TrackAuthoringGraph sourceGraph,
bool success,
IEnumerable orderedNodes,
TrackAuthoringDefinition? definition,
TrackAuthoringCompilation? compilation,
IEnumerable diagnostics)
{
+ if (sourceGraph is null)
+ {
+ throw new ArgumentNullException(nameof(sourceGraph));
+ }
+
if (orderedNodes is null)
{
throw new ArgumentNullException(nameof(orderedNodes));
@@ -28,6 +34,7 @@ internal TrackAuthoringGraphCompileResult(
throw new ArgumentNullException(nameof(diagnostics));
}
+ SourceGraph = sourceGraph;
Success = success;
_orderedNodes = new List(orderedNodes).AsReadOnly();
Definition = definition;
@@ -37,6 +44,11 @@ internal TrackAuthoringGraphCompileResult(
public bool Success { get; }
+ ///
+ /// Gets the immutable graph instance evaluated to produce this result.
+ ///
+ public TrackAuthoringGraph SourceGraph { get; }
+
public IReadOnlyList OrderedNodes => _orderedNodes;
public TrackAuthoringDefinition? Definition { get; }
diff --git a/Quantum.Track/Authoring/Graph/TrackAuthoringGraphCompiler.cs b/Quantum.Track/Authoring/Graph/TrackAuthoringGraphCompiler.cs
index 36beb45..7e24d8b 100644
--- a/Quantum.Track/Authoring/Graph/TrackAuthoringGraphCompiler.cs
+++ b/Quantum.Track/Authoring/Graph/TrackAuthoringGraphCompiler.cs
@@ -38,14 +38,14 @@ private static TrackAuthoringGraphCompileResult CompileCore(TrackAuthoringGraph
diagnostics.Add(new TrackAuthoringGraphDiagnostic(
TrackAuthoringGraphDiagnosticCode.EmptyGraph,
"The authoring graph must contain at least one section node."));
- return Failure(diagnostics);
+ return Failure(graph, diagnostics);
}
TrackAuthoringGraphRouteResult route =
TrackAuthoringGraphRouteValidator.Validate(graph);
if (!route.Success)
{
- return Failure(route.Diagnostics, route.OrderedNodes);
+ return Failure(graph, route.Diagnostics, route.OrderedNodes);
}
var orderedNodes = new List(route.OrderedNodes);
@@ -65,7 +65,7 @@ private static TrackAuthoringGraphCompileResult CompileCore(TrackAuthoringGraph
if (diagnostics.Count != 0)
{
- return Failure(diagnostics, orderedNodes);
+ return Failure(graph, diagnostics, orderedNodes);
}
TrackAuthoringDefinition definition;
@@ -84,7 +84,7 @@ exception is InvalidOperationException ||
exception is NotSupportedException)
{
diagnostics.Add(CreateCompilationDiagnostic(exception));
- return Failure(diagnostics, orderedNodes);
+ return Failure(graph, diagnostics, orderedNodes);
}
try
@@ -92,6 +92,7 @@ exception is InvalidOperationException ||
TrackAuthoringCompilation compilation =
TrackAuthoringDocumentBuilder.Compile(definition);
return new TrackAuthoringGraphCompileResult(
+ graph,
true,
orderedNodes,
definition,
@@ -105,6 +106,7 @@ exception is InvalidOperationException ||
{
diagnostics.Add(CreateCompilationDiagnostic(exception));
return new TrackAuthoringGraphCompileResult(
+ graph,
false,
orderedNodes,
definition,
@@ -122,10 +124,12 @@ private static TrackAuthoringGraphDiagnostic CreateCompilationDiagnostic(Excepti
}
private static TrackAuthoringGraphCompileResult Failure(
+ TrackAuthoringGraph sourceGraph,
IEnumerable diagnostics,
IEnumerable? orderedNodes = null)
{
return new TrackAuthoringGraphCompileResult(
+ sourceGraph,
false,
orderedNodes ?? Array.Empty(),
null,
diff --git a/docs/editor/m167-1-measurement-and-redundant-compilation-cleanup.md b/docs/editor/m167-1-measurement-and-redundant-compilation-cleanup.md
index 9438020..5344e74 100644
--- a/docs/editor/m167-1-measurement-and-redundant-compilation-cleanup.md
+++ b/docs/editor/m167-1-measurement-and-redundant-compilation-cleanup.md
@@ -8,8 +8,8 @@ Package V2 and the visible M166 editor workflow remain unchanged.
## Measurement hooks
-The measurement hooks are internal and opt-in so the public backend and editor
-contracts do not change:
+The measurement hooks are internal and opt-in, so measurement does not add a public
+backend or editor contract:
- `TrackAuthoringPipelineMeasurement` counts graph compiler invocations and records
total graph-compilation and `EngineeringSnapshot` build time within one scoped
@@ -20,10 +20,10 @@ contracts do not change:
The scopes use `AsyncLocal` ownership. Parallel tests can measure independent editor
operations without resetting global process counters.
-## Confirmed baseline
+## Confirmed pipeline before cleanup
The baseline counts below are asserted by
-`M167CompilationPipelineMeasurementTests` before the cleanup:
+`M167CompilationPipelineMeasurementTests` in commit `776b8be` before the cleanup:
| Operation | Graph compiler invocations | Engineering snapshots | Viewport projections |
|---|---:|---:|---:|
@@ -45,20 +45,65 @@ For `ApplyGraphEdit`, the five full compilations are:
Only the fourth candidate compilation becomes the active document compilation and
feeds the engineering and viewport projection. The other four results are discarded.
-## Cleanup target
-
-M167.1 will retain the already validated graph compilation through document
-replacement and history, provide a V2 export path for an existing successful compile
-result, and cache canonical committed package JSON. The target counts are:
-
-| Operation | Target graph compiler invocations |
-|---|---:|
-| ApplyGraphEdit, non-empty changed route | 1 |
-| ReplaceGraph, non-empty route | 1 |
-| Save | 0 |
-| Open | 1 |
-| Undo | 0 |
-| Redo | 0 |
+The other measured operations followed these paths:
+
+- `ReplaceGraph`: compile the candidate, then compile it again while exporting JSON
+ for dirty-state comparison.
+- `Save`: export/compile once to write the file, then export/compile again while
+ establishing the clean savepoint.
+- `Open`: compile the imported graph, export/compile while constructing the document,
+ then export/compile again when the file service marks it clean.
+- `Undo` and `Redo`: call `ReplaceGraph`, producing the same two compilations as a
+ direct replacement.
+
+Serialization itself did not compile, but every graph-to-V2 export did. Engineering
+snapshot and viewport projection construction occurred only from the one compilation
+accepted by the active document; those stages were not duplicated.
+
+## Implemented cleanup
+
+M167.1 now:
+
+- binds every `TrackAuthoringGraphCompileResult` to the exact immutable graph instance
+ that produced it;
+- provides a V2 export overload that accepts that exact result and rejects a result
+ from another graph;
+- creates one prepared editor graph state containing the graph, compilation, and
+ canonical V2 JSON;
+- retains the prepared before/after states in normal graph undo history; and
+- caches the active canonical package JSON for save and dirty-state comparison.
+
+The existing two-argument V2 export and public `ReplaceGraph` behavior remain
+available. The overload is the only public API addition, and is required to reuse a
+compile result safely across the `Quantum.Track` to `Quantum.IO` boundary.
+
+## Before/after benchmark summary
+
+The same scoped instrumentation tests produce these deterministic invocation counts:
+
+| Operation | Before | After | Eliminated | Engineering snapshots after | Viewport projections after |
+|---|---:|---:|---:|---:|---:|
+| ApplyGraphEdit, non-empty changed route | 5 | 1 | 4 | 1 | 1 |
+| ReplaceGraph, non-empty route | 2 | 1 | 1 | 1 when active | 1 when active |
+| Save | 2 | 0 | 2 | 0 | 0 |
+| Open | 3 | 1 | 2 | 1 | 1 |
+| Undo to non-empty route | 2 | 0 | 2 | 1 | 1 |
+| Redo to non-empty route | 2 | 0 | 2 | 1 | 1 |
+
+Compilation, engineering snapshot, and viewport projection elapsed times are recorded
+as `TimeSpan` totals for each measurement scope. The regression tests verify that a
+measured invocation records positive elapsed time and that operations with zero work
+record zero. Wall-clock budgets are intentionally deferred until representative small,
+realistic, and long-route fixtures are established; M167.1 provides the hooks needed
+for that benchmark work without introducing a benchmark dependency.
+
+## Behavioral compatibility
+
+The accepted compilation is still the source for `TrackDocument`,
+`EngineeringSnapshot`, and viewport projection. Package JSON remains the canonical,
+indented Track Layout V2 representation, and the reuse path is tested byte-for-byte
+against the existing export path. Save/reopen, dirty-state transitions, graph editing,
+and non-interactive undo/redo retain their M166 behavior.
Engineering snapshots and viewport projections remain one per changed active
compilation. No rendering, selection, dirty-state, persistence, or history behavior is
From 5254e0167533eb4a8c51c0f4cc1ca68135fb0f5e Mon Sep 17 00:00:00 2001
From: Coasterpete <83011124+Coasterpete@users.noreply.github.com>
Date: Wed, 22 Jul 2026 15:39:53 -0400
Subject: [PATCH 03/11] M167.2: extract frontend-neutral prepared graph state
---
.../Authoring/PreparedTrackGraphState.cs | 193 ++++++++++++++++++
.../Quantum.Application.csproj | 13 ++
.../Quantum.Editor.Avalonia.csproj | 1 +
.../Services/Documents/TrackEditorDocument.cs | 28 +--
.../Documents/TrackEditorGraphState.cs | 47 -----
.../Services/EditorWorkspace.cs | 12 +-
.../UndoRedo/TrackGraphSnapshotOperation.cs | 9 +-
.../Core/BackendDependencyContractTests.cs | 6 +-
Quantum.Tests/Quantum.Tests.csproj | 1 +
QuantumCoasterWorks.sln | 14 ++
10 files changed, 254 insertions(+), 70 deletions(-)
create mode 100644 Quantum.Application/Authoring/PreparedTrackGraphState.cs
create mode 100644 Quantum.Application/Quantum.Application.csproj
delete mode 100644 Quantum.Editor.Avalonia/Services/Documents/TrackEditorGraphState.cs
diff --git a/Quantum.Application/Authoring/PreparedTrackGraphState.cs b/Quantum.Application/Authoring/PreparedTrackGraphState.cs
new file mode 100644
index 0000000..504287c
--- /dev/null
+++ b/Quantum.Application/Authoring/PreparedTrackGraphState.cs
@@ -0,0 +1,193 @@
+using System;
+using System.Linq;
+using System.Text;
+using Quantum.IO.TrackLayout.V2;
+using Quantum.Track.Authoring;
+
+namespace Quantum.Application.Authoring
+{
+ ///
+ /// One immutable source graph together with its exact successful compilation and
+ /// canonical Track Layout Package V2 JSON. Empty graphs intentionally have neither
+ /// a compilation nor persistable package JSON.
+ ///
+ public sealed class PreparedTrackGraphState
+ {
+ private PreparedTrackGraphState(
+ TrackAuthoringGraph graph,
+ TrackLayoutPackageV2GraphAncillaryState ancillaryState,
+ TrackAuthoringGraphCompileResult? graphCompileResult,
+ string? canonicalPackageJson)
+ {
+ Graph = graph ?? throw new ArgumentNullException(nameof(graph));
+ AncillaryState = ancillaryState ??
+ throw new ArgumentNullException(nameof(ancillaryState));
+
+ if (graph.Nodes.Count == 0)
+ {
+ if (graphCompileResult != null || canonicalPackageJson != null)
+ {
+ throw new ArgumentException(
+ "An empty graph cannot have a compilation or package snapshot.",
+ nameof(graphCompileResult));
+ }
+ }
+ else if (graphCompileResult is null ||
+ !graphCompileResult.Success ||
+ graphCompileResult.Compilation is null ||
+ !ReferenceEquals(graphCompileResult.SourceGraph, graph) ||
+ canonicalPackageJson is null)
+ {
+ throw new ArgumentException(
+ "A non-empty graph state requires its exact successful compilation and package snapshot.",
+ nameof(graphCompileResult));
+ }
+
+ GraphCompileResult = graphCompileResult;
+ CanonicalPackageJson = canonicalPackageJson;
+ RetainedPackageByteCount = canonicalPackageJson is null
+ ? 0
+ : Encoding.UTF8.GetByteCount(canonicalPackageJson);
+ }
+
+ public TrackAuthoringGraph Graph { get; }
+
+ public TrackLayoutPackageV2GraphAncillaryState AncillaryState { get; }
+
+ public TrackAuthoringGraphCompileResult? GraphCompileResult { get; }
+
+ public string? CanonicalPackageJson { get; }
+
+ ///
+ /// UTF-8 size of the retained canonical package. This intentionally does not
+ /// estimate the graph or compilation object graph.
+ ///
+ public int RetainedPackageByteCount { get; }
+
+ public static PreparedTrackGraphState Prepare(
+ TrackAuthoringGraph graph,
+ TrackLayoutPackageV2GraphAncillaryState ancillaryState)
+ {
+ if (graph is null)
+ {
+ throw new ArgumentNullException(nameof(graph));
+ }
+
+ if (graph.Nodes.Count == 0)
+ {
+ return FromEvaluation(graph, ancillaryState, graphCompileResult: null);
+ }
+
+ TrackAuthoringGraphCompileResult compilation =
+ TrackAuthoringGraphCompiler.Compile(graph);
+ return FromEvaluation(graph, ancillaryState, compilation);
+ }
+
+ ///
+ /// Prepares persistence from an already evaluated graph without compiling it
+ /// again.
+ ///
+ public static PreparedTrackGraphState FromEvaluation(
+ TrackAuthoringGraph graph,
+ TrackLayoutPackageV2GraphAncillaryState ancillaryState,
+ TrackAuthoringGraphCompileResult? graphCompileResult)
+ {
+ if (graph is null)
+ {
+ throw new ArgumentNullException(nameof(graph));
+ }
+
+ if (ancillaryState is null)
+ {
+ throw new ArgumentNullException(nameof(ancillaryState));
+ }
+
+ if (graph.Nodes.Count == 0)
+ {
+ if (graphCompileResult != null)
+ {
+ throw new ArgumentException(
+ "An empty graph cannot have a compilation.",
+ nameof(graphCompileResult));
+ }
+
+ return FromPreparedData(
+ graph,
+ ancillaryState,
+ graphCompileResult: null,
+ canonicalPackageJson: null);
+ }
+
+ if (graphCompileResult is null ||
+ !graphCompileResult.Success ||
+ graphCompileResult.Compilation is null)
+ {
+ throw new ArgumentException(
+ "A non-empty graph requires a successful compilation.",
+ nameof(graphCompileResult));
+ }
+
+ TrackLayoutPackageV2GraphExportResult export =
+ TrackLayoutPackageV2GraphAdapter.Export(
+ graph,
+ ancillaryState,
+ graphCompileResult);
+ if (!export.Success || export.Package is null)
+ {
+ string details = export.GraphDiagnostics.Count != 0
+ ? string.Join(
+ " ",
+ export.GraphDiagnostics.Select(diagnostic =>
+ $"{diagnostic.Code}: {diagnostic.Message}"))
+ : string.Join(
+ " ",
+ export.PackageDiagnostics.Select(diagnostic =>
+ $"{diagnostic.Code} at {diagnostic.Path}: {diagnostic.Message}"));
+ throw new InvalidOperationException(
+ string.IsNullOrEmpty(details)
+ ? "The Track Layout Package V2 export failed without diagnostics."
+ : "The authoring graph could not be prepared for persistence: " + details);
+ }
+
+ string canonicalPackageJson = TrackLayoutPackageV2Json.Serialize(
+ export.Package,
+ indented: true);
+ return FromPreparedData(
+ graph,
+ ancillaryState,
+ graphCompileResult,
+ canonicalPackageJson);
+ }
+
+ ///
+ /// Rehydrates an already prepared state without repeating compilation, export,
+ /// or serialization. The caller must supply the canonical JSON originally
+ /// prepared for this exact graph and compilation.
+ ///
+ public static PreparedTrackGraphState FromPreparedData(
+ TrackAuthoringGraph graph,
+ TrackLayoutPackageV2GraphAncillaryState ancillaryState,
+ TrackAuthoringGraphCompileResult? graphCompileResult,
+ string? canonicalPackageJson)
+ {
+ return new PreparedTrackGraphState(
+ graph,
+ ancillaryState,
+ graphCompileResult,
+ canonicalPackageJson);
+ }
+
+ public bool HasSameCanonicalContent(PreparedTrackGraphState other)
+ {
+ if (other is null)
+ {
+ throw new ArgumentNullException(nameof(other));
+ }
+
+ return string.Equals(
+ CanonicalPackageJson,
+ other.CanonicalPackageJson,
+ StringComparison.Ordinal);
+ }
+ }
+}
diff --git a/Quantum.Application/Quantum.Application.csproj b/Quantum.Application/Quantum.Application.csproj
new file mode 100644
index 0000000..3caf31e
--- /dev/null
+++ b/Quantum.Application/Quantum.Application.csproj
@@ -0,0 +1,13 @@
+
+
+
+ netstandard2.1
+ enable
+
+
+
+
+
+
+
+
diff --git a/Quantum.Editor.Avalonia/Quantum.Editor.Avalonia.csproj b/Quantum.Editor.Avalonia/Quantum.Editor.Avalonia.csproj
index 4af7597..f1e1683 100644
--- a/Quantum.Editor.Avalonia/Quantum.Editor.Avalonia.csproj
+++ b/Quantum.Editor.Avalonia/Quantum.Editor.Avalonia.csproj
@@ -19,6 +19,7 @@
+
diff --git a/Quantum.Editor.Avalonia/Services/Documents/TrackEditorDocument.cs b/Quantum.Editor.Avalonia/Services/Documents/TrackEditorDocument.cs
index a6e8a41..0880c90 100644
--- a/Quantum.Editor.Avalonia/Services/Documents/TrackEditorDocument.cs
+++ b/Quantum.Editor.Avalonia/Services/Documents/TrackEditorDocument.cs
@@ -1,3 +1,4 @@
+using Quantum.Application.Authoring;
using Quantum.IO.TrackLayout.V2;
using Quantum.Track;
using Quantum.Track.Authoring;
@@ -144,7 +145,7 @@ public string CapturePackageJson()
return currentPackageJson!;
}
- internal TrackEditorGraphState CaptureGraphState()
+ internal PreparedTrackGraphState CaptureGraphState()
{
if (graph is null)
{
@@ -152,10 +153,14 @@ internal TrackEditorGraphState CaptureGraphState()
"This editor document does not have an editable authoring graph.");
}
- return new TrackEditorGraphState(graph, GraphCompileResult, currentPackageJson);
+ return PreparedTrackGraphState.FromPreparedData(
+ graph,
+ ancillaryState!,
+ GraphCompileResult,
+ currentPackageJson);
}
- internal TrackEditorGraphState PrepareGraphState(
+ internal PreparedTrackGraphState PrepareGraphState(
TrackAuthoringGraph candidateGraph,
TrackAuthoringGraphCompileResult? candidateCompilation)
{
@@ -168,7 +173,10 @@ internal TrackEditorGraphState PrepareGraphState(
if (candidateGraph.Nodes.Count == 0)
{
- return new TrackEditorGraphState(candidateGraph, null, null);
+ return PreparedTrackGraphState.FromEvaluation(
+ candidateGraph,
+ ancillaryState,
+ graphCompileResult: null);
}
if (candidateCompilation is null ||
@@ -180,14 +188,10 @@ internal TrackEditorGraphState PrepareGraphState(
nameof(candidateCompilation));
}
- string packageJson = SerializePackage(
+ return PreparedTrackGraphState.FromEvaluation(
candidateGraph,
ancillaryState,
candidateCompilation);
- return new TrackEditorGraphState(
- candidateGraph,
- candidateCompilation,
- packageJson);
}
public void ReplaceGraph(TrackAuthoringGraph candidateGraph)
@@ -226,7 +230,7 @@ public void ReplaceGraph(TrackAuthoringGraph candidateGraph)
ReplaceGraphState(PrepareGraphState(candidateGraph, candidateCompilation));
}
- internal void ReplaceGraphState(TrackEditorGraphState candidateState)
+ internal void ReplaceGraphState(PreparedTrackGraphState candidateState)
{
ArgumentNullException.ThrowIfNull(candidateState);
if (graph is null || ancillaryState is null)
@@ -238,7 +242,7 @@ internal void ReplaceGraphState(TrackEditorGraphState candidateState)
bool candidateIsDirty = explicitlyDirty ||
!string.Equals(
cleanPackageJson,
- candidateState.PackageJson,
+ candidateState.CanonicalPackageJson,
StringComparison.Ordinal);
if (candidateState.GraphCompileResult is null)
{
@@ -249,7 +253,7 @@ internal void ReplaceGraphState(TrackEditorGraphState candidateState)
CommitCompiledGraph(
candidateState.Graph,
candidateState.GraphCompileResult,
- candidateState.PackageJson!,
+ candidateState.CanonicalPackageJson!,
candidateIsDirty);
}
diff --git a/Quantum.Editor.Avalonia/Services/Documents/TrackEditorGraphState.cs b/Quantum.Editor.Avalonia/Services/Documents/TrackEditorGraphState.cs
deleted file mode 100644
index 7564289..0000000
--- a/Quantum.Editor.Avalonia/Services/Documents/TrackEditorGraphState.cs
+++ /dev/null
@@ -1,47 +0,0 @@
-using Quantum.Track.Authoring;
-
-namespace Quantum.Editor.Avalonia.Services.Documents;
-
-///
-/// One immutable graph snapshot together with the exact successful compilation and
-/// canonical package JSON derived from it. Empty graphs intentionally have neither.
-///
-internal sealed class TrackEditorGraphState
-{
- public TrackEditorGraphState(
- TrackAuthoringGraph graph,
- TrackAuthoringGraphCompileResult? graphCompileResult,
- string? packageJson)
- {
- Graph = graph ?? throw new ArgumentNullException(nameof(graph));
-
- if (graph.Nodes.Count == 0)
- {
- if (graphCompileResult is not null || packageJson is not null)
- {
- throw new ArgumentException(
- "An empty graph cannot have a compilation or package snapshot.",
- nameof(graphCompileResult));
- }
- }
- else if (graphCompileResult is null ||
- !graphCompileResult.Success ||
- graphCompileResult.Compilation is null ||
- !ReferenceEquals(graphCompileResult.SourceGraph, graph) ||
- packageJson is null)
- {
- throw new ArgumentException(
- "A non-empty graph state requires its exact successful compilation and package snapshot.",
- nameof(graphCompileResult));
- }
-
- GraphCompileResult = graphCompileResult;
- PackageJson = packageJson;
- }
-
- public TrackAuthoringGraph Graph { get; }
-
- public TrackAuthoringGraphCompileResult? GraphCompileResult { get; }
-
- public string? PackageJson { get; }
-}
diff --git a/Quantum.Editor.Avalonia/Services/EditorWorkspace.cs b/Quantum.Editor.Avalonia/Services/EditorWorkspace.cs
index a3c39e4..b08678f 100644
--- a/Quantum.Editor.Avalonia/Services/EditorWorkspace.cs
+++ b/Quantum.Editor.Avalonia/Services/EditorWorkspace.cs
@@ -1,4 +1,5 @@
using System.Globalization;
+using Quantum.Application.Authoring;
using Quantum.Editor.Avalonia.Models;
using Quantum.Editor.Avalonia.Services.Commands;
using Quantum.Editor.Avalonia.Services.Documents;
@@ -141,7 +142,7 @@ public bool ApplyGraphEdit(
return false;
}
- TrackEditorGraphState beforeState = document.CaptureGraphState();
+ PreparedTrackGraphState beforeState = document.CaptureGraphState();
TrackAuthoringGraph beforeGraph = beforeState.Graph;
TrackAuthoringGraph candidateGraph;
@@ -187,14 +188,15 @@ exception is InvalidOperationException ||
try
{
- TrackEditorGraphState afterState = document.PrepareGraphState(
+ PreparedTrackGraphState afterState = document.PrepareGraphState(
candidateGraph,
candidateCompilation);
- if (beforeState.PackageJson is not null && afterState.PackageJson is not null)
+ if (beforeState.CanonicalPackageJson is not null &&
+ afterState.CanonicalPackageJson is not null)
{
if (string.Equals(
- beforeState.PackageJson,
- afterState.PackageJson,
+ beforeState.CanonicalPackageJson,
+ afterState.CanonicalPackageJson,
StringComparison.Ordinal))
{
SetStatus("No graph values changed.");
diff --git a/Quantum.Editor.Avalonia/Services/UndoRedo/TrackGraphSnapshotOperation.cs b/Quantum.Editor.Avalonia/Services/UndoRedo/TrackGraphSnapshotOperation.cs
index 47d6af8..3b4a61b 100644
--- a/Quantum.Editor.Avalonia/Services/UndoRedo/TrackGraphSnapshotOperation.cs
+++ b/Quantum.Editor.Avalonia/Services/UndoRedo/TrackGraphSnapshotOperation.cs
@@ -1,3 +1,4 @@
+using Quantum.Application.Authoring;
using Quantum.Editor.Avalonia.Services.Documents;
using Quantum.Track.Authoring;
@@ -11,8 +12,8 @@ public sealed class TrackGraphSnapshotOperation : IUndoableEditorOperation
private readonly TrackEditorDocument document;
private readonly TrackAuthoringGraph? beforeGraph;
private readonly TrackAuthoringGraph? afterGraph;
- private readonly TrackEditorGraphState? beforeState;
- private readonly TrackEditorGraphState? afterState;
+ private readonly PreparedTrackGraphState? beforeState;
+ private readonly PreparedTrackGraphState? afterState;
public TrackGraphSnapshotOperation(
string description,
@@ -31,8 +32,8 @@ public TrackGraphSnapshotOperation(
internal TrackGraphSnapshotOperation(
string description,
TrackEditorDocument document,
- TrackEditorGraphState beforeState,
- TrackEditorGraphState afterState)
+ PreparedTrackGraphState beforeState,
+ PreparedTrackGraphState afterState)
{
Description = string.IsNullOrWhiteSpace(description)
? throw new ArgumentException("Operation description is required.", nameof(description))
diff --git a/Quantum.Tests/Core/BackendDependencyContractTests.cs b/Quantum.Tests/Core/BackendDependencyContractTests.cs
index 7fb0907..064c38b 100644
--- a/Quantum.Tests/Core/BackendDependencyContractTests.cs
+++ b/Quantum.Tests/Core/BackendDependencyContractTests.cs
@@ -15,7 +15,8 @@ public sealed class BackendDependencyContractTests
"Quantum.Physics",
"Quantum.FVD",
"Quantum.IO",
- "Quantum.Debug"
+ "Quantum.Debug",
+ "Quantum.Application"
};
private static readonly string[] BackendProjectPaths =
@@ -27,7 +28,8 @@ public sealed class BackendDependencyContractTests
"Quantum.Physics/Quantum.Physics.csproj",
"Quantum.FVD/Quantum.FVD.csproj",
"Quantum.IO/Quantum.IO.csproj",
- "Quantum.Debug/Quantum.Debug.csproj"
+ "Quantum.Debug/Quantum.Debug.csproj",
+ "Quantum.Application/Quantum.Application.csproj"
};
private static readonly string[] ForbiddenFrontendOrRendererNames =
diff --git a/Quantum.Tests/Quantum.Tests.csproj b/Quantum.Tests/Quantum.Tests.csproj
index 67d2710..a68aa28 100644
--- a/Quantum.Tests/Quantum.Tests.csproj
+++ b/Quantum.Tests/Quantum.Tests.csproj
@@ -29,6 +29,7 @@
+
diff --git a/QuantumCoasterWorks.sln b/QuantumCoasterWorks.sln
index 4b42509..b0f2d94 100644
--- a/QuantumCoasterWorks.sln
+++ b/QuantumCoasterWorks.sln
@@ -37,6 +37,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Editor", "Editor", "{282514
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Quantum.Editor.Avalonia", "Quantum.Editor.Avalonia\Quantum.Editor.Avalonia.csproj", "{302A6D9D-12C1-439A-8E79-8AC14923D60E}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Quantum.Application", "Quantum.Application\Quantum.Application.csproj", "{838AA2BD-CC71-4E56-B9D5-F9067BC88990}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -179,6 +181,18 @@ Global
{302A6D9D-12C1-439A-8E79-8AC14923D60E}.Release|x64.Build.0 = Release|Any CPU
{302A6D9D-12C1-439A-8E79-8AC14923D60E}.Release|x86.ActiveCfg = Release|Any CPU
{302A6D9D-12C1-439A-8E79-8AC14923D60E}.Release|x86.Build.0 = Release|Any CPU
+ {838AA2BD-CC71-4E56-B9D5-F9067BC88990}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {838AA2BD-CC71-4E56-B9D5-F9067BC88990}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {838AA2BD-CC71-4E56-B9D5-F9067BC88990}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {838AA2BD-CC71-4E56-B9D5-F9067BC88990}.Debug|x64.Build.0 = Debug|Any CPU
+ {838AA2BD-CC71-4E56-B9D5-F9067BC88990}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {838AA2BD-CC71-4E56-B9D5-F9067BC88990}.Debug|x86.Build.0 = Debug|Any CPU
+ {838AA2BD-CC71-4E56-B9D5-F9067BC88990}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {838AA2BD-CC71-4E56-B9D5-F9067BC88990}.Release|Any CPU.Build.0 = Release|Any CPU
+ {838AA2BD-CC71-4E56-B9D5-F9067BC88990}.Release|x64.ActiveCfg = Release|Any CPU
+ {838AA2BD-CC71-4E56-B9D5-F9067BC88990}.Release|x64.Build.0 = Release|Any CPU
+ {838AA2BD-CC71-4E56-B9D5-F9067BC88990}.Release|x86.ActiveCfg = Release|Any CPU
+ {838AA2BD-CC71-4E56-B9D5-F9067BC88990}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
From 905fb3d1253f07d578cc431ed5741431e798821c Mon Sep 17 00:00:00 2001
From: Coasterpete <83011124+Coasterpete@users.noreply.github.com>
Date: Wed, 22 Jul 2026 15:47:15 -0400
Subject: [PATCH 04/11] M167.2: add transactional track authoring session
---
.../Authoring/AuthoringHistory.cs | 101 ++++
.../Authoring/AuthoringRevisions.cs | 206 +++++++++
.../Authoring/AuthoringSessionDiagnostics.cs | 33 ++
.../Authoring/AuthoringSessionResults.cs | 72 +++
.../Authoring/AuthoringSessionSnapshot.cs | 54 +++
.../Authoring/CommittedTrackState.cs | 28 ++
.../Authoring/EvaluatedTrackCandidate.cs | 41 ++
.../InteractiveAuthoringTransaction.cs | 85 ++++
.../Authoring/TrackAuthoringSession.cs | 398 ++++++++++++++++
.../Application/TrackAuthoringSessionTests.cs | 435 ++++++++++++++++++
.../Core/BackendDependencyContractTests.cs | 14 +
.../TrackAuthoringCandidateEvaluatorTests.cs | 25 +
.../TrackAuthoringCandidateEvaluation.cs | 4 +-
.../Graph/TrackAuthoringCandidateEvaluator.cs | 13 +
14 files changed, 1507 insertions(+), 2 deletions(-)
create mode 100644 Quantum.Application/Authoring/AuthoringHistory.cs
create mode 100644 Quantum.Application/Authoring/AuthoringRevisions.cs
create mode 100644 Quantum.Application/Authoring/AuthoringSessionDiagnostics.cs
create mode 100644 Quantum.Application/Authoring/AuthoringSessionResults.cs
create mode 100644 Quantum.Application/Authoring/AuthoringSessionSnapshot.cs
create mode 100644 Quantum.Application/Authoring/CommittedTrackState.cs
create mode 100644 Quantum.Application/Authoring/EvaluatedTrackCandidate.cs
create mode 100644 Quantum.Application/Authoring/InteractiveAuthoringTransaction.cs
create mode 100644 Quantum.Application/Authoring/TrackAuthoringSession.cs
create mode 100644 Quantum.Tests/Application/TrackAuthoringSessionTests.cs
diff --git a/Quantum.Application/Authoring/AuthoringHistory.cs b/Quantum.Application/Authoring/AuthoringHistory.cs
new file mode 100644
index 0000000..5cdd294
--- /dev/null
+++ b/Quantum.Application/Authoring/AuthoringHistory.cs
@@ -0,0 +1,101 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace Quantum.Application.Authoring
+{
+ public sealed class AuthoringHistoryEntry
+ {
+ internal AuthoringHistoryEntry(
+ string description,
+ PreparedTrackGraphState beforeState,
+ PreparedTrackGraphState afterState)
+ {
+ Description = string.IsNullOrWhiteSpace(description)
+ ? throw new ArgumentException("A history description is required.", nameof(description))
+ : description;
+ BeforeState = beforeState ?? throw new ArgumentNullException(nameof(beforeState));
+ AfterState = afterState ?? throw new ArgumentNullException(nameof(afterState));
+ }
+
+ public string Description { get; }
+
+ public PreparedTrackGraphState BeforeState { get; }
+
+ public PreparedTrackGraphState AfterState { get; }
+
+ public long RetainedPackageByteCount =>
+ (long)BeforeState.RetainedPackageByteCount + AfterState.RetainedPackageByteCount;
+ }
+
+ public sealed class AuthoringHistory
+ {
+ private readonly Stack undo =
+ new Stack();
+ private readonly Stack redo =
+ new Stack();
+
+ public int UndoCount => undo.Count;
+
+ public int RedoCount => redo.Count;
+
+ public bool CanUndo => undo.Count != 0;
+
+ public bool CanRedo => redo.Count != 0;
+
+ public string? UndoDescription => CanUndo ? undo.Peek().Description : null;
+
+ public string? RedoDescription => CanRedo ? redo.Peek().Description : null;
+
+ ///
+ /// Sum of retained canonical JSON byte sizes. Object-graph memory is not
+ /// estimated and shared states may be counted more than once.
+ ///
+ public long RetainedPackageByteCount =>
+ undo.Sum(entry => entry.RetainedPackageByteCount) +
+ redo.Sum(entry => entry.RetainedPackageByteCount);
+
+ internal void Record(AuthoringHistoryEntry entry)
+ {
+ if (entry is null)
+ {
+ throw new ArgumentNullException(nameof(entry));
+ }
+
+ undo.Push(entry);
+ redo.Clear();
+ }
+
+ internal bool TryUndo(out AuthoringHistoryEntry? entry)
+ {
+ if (!CanUndo)
+ {
+ entry = null;
+ return false;
+ }
+
+ entry = undo.Pop();
+ redo.Push(entry);
+ return true;
+ }
+
+ internal bool TryRedo(out AuthoringHistoryEntry? entry)
+ {
+ if (!CanRedo)
+ {
+ entry = null;
+ return false;
+ }
+
+ entry = redo.Pop();
+ undo.Push(entry);
+ return true;
+ }
+
+ internal void Clear()
+ {
+ undo.Clear();
+ redo.Clear();
+ }
+ }
+}
diff --git a/Quantum.Application/Authoring/AuthoringRevisions.cs b/Quantum.Application/Authoring/AuthoringRevisions.cs
new file mode 100644
index 0000000..bbb05b4
--- /dev/null
+++ b/Quantum.Application/Authoring/AuthoringRevisions.cs
@@ -0,0 +1,206 @@
+using System;
+
+namespace Quantum.Application.Authoring
+{
+ public readonly struct AuthoringSessionId : IEquatable
+ {
+ public AuthoringSessionId(Guid value)
+ {
+ if (value == Guid.Empty)
+ {
+ throw new ArgumentException("A session ID cannot be empty.", nameof(value));
+ }
+
+ Value = value;
+ }
+
+ public Guid Value { get; }
+
+ public static AuthoringSessionId New()
+ {
+ return new AuthoringSessionId(Guid.NewGuid());
+ }
+
+ public bool Equals(AuthoringSessionId other) => Value.Equals(other.Value);
+
+ public override bool Equals(object? obj) =>
+ obj is AuthoringSessionId && Equals((AuthoringSessionId)obj);
+
+ public override int GetHashCode() => Value.GetHashCode();
+
+ public override string ToString() => Value.ToString("N");
+
+ public static bool operator ==(AuthoringSessionId left, AuthoringSessionId right) =>
+ left.Equals(right);
+
+ public static bool operator !=(AuthoringSessionId left, AuthoringSessionId right) =>
+ !left.Equals(right);
+ }
+
+ public readonly struct CommittedSourceRevision : IEquatable
+ {
+ public CommittedSourceRevision(AuthoringSessionId sessionId, long sequence)
+ {
+ if (sessionId.Value == Guid.Empty)
+ {
+ throw new ArgumentException("A committed revision requires a session ID.", nameof(sessionId));
+ }
+
+ if (sequence < 0)
+ {
+ throw new ArgumentOutOfRangeException(nameof(sequence));
+ }
+
+ SessionId = sessionId;
+ Sequence = sequence;
+ }
+
+ public AuthoringSessionId SessionId { get; }
+
+ public long Sequence { get; }
+
+ public bool Equals(CommittedSourceRevision other) =>
+ SessionId.Equals(other.SessionId) && Sequence == other.Sequence;
+
+ public override bool Equals(object? obj) =>
+ obj is CommittedSourceRevision && Equals((CommittedSourceRevision)obj);
+
+ public override int GetHashCode() =>
+ unchecked((SessionId.GetHashCode() * 397) ^ Sequence.GetHashCode());
+
+ public override string ToString() => $"{SessionId}:commit:{Sequence}";
+
+ public static bool operator ==(CommittedSourceRevision left, CommittedSourceRevision right) =>
+ left.Equals(right);
+
+ public static bool operator !=(CommittedSourceRevision left, CommittedSourceRevision right) =>
+ !left.Equals(right);
+ }
+
+ public readonly struct TransactionRevision : IEquatable
+ {
+ public TransactionRevision(AuthoringSessionId sessionId, long sequence)
+ {
+ if (sessionId.Value == Guid.Empty)
+ {
+ throw new ArgumentException("A transaction revision requires a session ID.", nameof(sessionId));
+ }
+
+ if (sequence <= 0)
+ {
+ throw new ArgumentOutOfRangeException(nameof(sequence));
+ }
+
+ SessionId = sessionId;
+ Sequence = sequence;
+ }
+
+ public AuthoringSessionId SessionId { get; }
+
+ public long Sequence { get; }
+
+ public bool Equals(TransactionRevision other) =>
+ SessionId.Equals(other.SessionId) && Sequence == other.Sequence;
+
+ public override bool Equals(object? obj) =>
+ obj is TransactionRevision && Equals((TransactionRevision)obj);
+
+ public override int GetHashCode() =>
+ unchecked((SessionId.GetHashCode() * 397) ^ Sequence.GetHashCode());
+
+ public override string ToString() => $"{SessionId}:transaction:{Sequence}";
+
+ public static bool operator ==(TransactionRevision left, TransactionRevision right) =>
+ left.Equals(right);
+
+ public static bool operator !=(TransactionRevision left, TransactionRevision right) =>
+ !left.Equals(right);
+ }
+
+ public readonly struct ProvisionalEditRevision : IEquatable
+ {
+ public ProvisionalEditRevision(TransactionRevision transactionRevision, long sequence)
+ {
+ if (transactionRevision.SessionId.Value == Guid.Empty)
+ {
+ throw new ArgumentException(
+ "A provisional revision requires a transaction revision.",
+ nameof(transactionRevision));
+ }
+
+ if (sequence <= 0)
+ {
+ throw new ArgumentOutOfRangeException(nameof(sequence));
+ }
+
+ TransactionRevision = transactionRevision;
+ Sequence = sequence;
+ }
+
+ public TransactionRevision TransactionRevision { get; }
+
+ public long Sequence { get; }
+
+ public bool Equals(ProvisionalEditRevision other) =>
+ TransactionRevision.Equals(other.TransactionRevision) && Sequence == other.Sequence;
+
+ public override bool Equals(object? obj) =>
+ obj is ProvisionalEditRevision && Equals((ProvisionalEditRevision)obj);
+
+ public override int GetHashCode() =>
+ unchecked((TransactionRevision.GetHashCode() * 397) ^ Sequence.GetHashCode());
+
+ public override string ToString() => $"{TransactionRevision}:provisional:{Sequence}";
+
+ public static bool operator ==(ProvisionalEditRevision left, ProvisionalEditRevision right) =>
+ left.Equals(right);
+
+ public static bool operator !=(ProvisionalEditRevision left, ProvisionalEditRevision right) =>
+ !left.Equals(right);
+ }
+
+ public readonly struct EvaluatedCandidateRevision : IEquatable
+ {
+ public EvaluatedCandidateRevision(
+ CommittedSourceRevision baseCommittedRevision,
+ ProvisionalEditRevision provisionalEditRevision)
+ {
+ if (baseCommittedRevision.SessionId !=
+ provisionalEditRevision.TransactionRevision.SessionId)
+ {
+ throw new ArgumentException(
+ "Candidate base and provisional revisions must belong to the same session.",
+ nameof(provisionalEditRevision));
+ }
+
+ BaseCommittedRevision = baseCommittedRevision;
+ ProvisionalEditRevision = provisionalEditRevision;
+ }
+
+ public CommittedSourceRevision BaseCommittedRevision { get; }
+
+ public ProvisionalEditRevision ProvisionalEditRevision { get; }
+
+ public bool Equals(EvaluatedCandidateRevision other) =>
+ BaseCommittedRevision.Equals(other.BaseCommittedRevision) &&
+ ProvisionalEditRevision.Equals(other.ProvisionalEditRevision);
+
+ public override bool Equals(object? obj) =>
+ obj is EvaluatedCandidateRevision && Equals((EvaluatedCandidateRevision)obj);
+
+ public override int GetHashCode() =>
+ unchecked((BaseCommittedRevision.GetHashCode() * 397) ^
+ ProvisionalEditRevision.GetHashCode());
+
+ public override string ToString() =>
+ $"{BaseCommittedRevision}|{ProvisionalEditRevision}";
+
+ public static bool operator ==(
+ EvaluatedCandidateRevision left,
+ EvaluatedCandidateRevision right) => left.Equals(right);
+
+ public static bool operator !=(
+ EvaluatedCandidateRevision left,
+ EvaluatedCandidateRevision right) => !left.Equals(right);
+ }
+}
diff --git a/Quantum.Application/Authoring/AuthoringSessionDiagnostics.cs b/Quantum.Application/Authoring/AuthoringSessionDiagnostics.cs
new file mode 100644
index 0000000..66cd6d0
--- /dev/null
+++ b/Quantum.Application/Authoring/AuthoringSessionDiagnostics.cs
@@ -0,0 +1,33 @@
+using System;
+
+namespace Quantum.Application.Authoring
+{
+ public enum AuthoringSessionDiagnosticCode
+ {
+ NoActiveTransaction = 0,
+ ActiveTransactionExists = 1,
+ TransactionRevisionMismatch = 2,
+ CommittedRevisionMismatch = 3,
+ CandidateRevisionMismatch = 4,
+ CandidateRejected = 5,
+ PersistencePreparationFailed = 6,
+ TransactionActive = 7
+ }
+
+ public sealed class AuthoringSessionDiagnostic
+ {
+ public AuthoringSessionDiagnostic(
+ AuthoringSessionDiagnosticCode code,
+ string message)
+ {
+ Code = code;
+ Message = string.IsNullOrWhiteSpace(message)
+ ? throw new ArgumentException("A diagnostic message is required.", nameof(message))
+ : message;
+ }
+
+ public AuthoringSessionDiagnosticCode Code { get; }
+
+ public string Message { get; }
+ }
+}
diff --git a/Quantum.Application/Authoring/AuthoringSessionResults.cs b/Quantum.Application/Authoring/AuthoringSessionResults.cs
new file mode 100644
index 0000000..a42a72a
--- /dev/null
+++ b/Quantum.Application/Authoring/AuthoringSessionResults.cs
@@ -0,0 +1,72 @@
+using System;
+using System.Collections.Generic;
+
+namespace Quantum.Application.Authoring
+{
+ public sealed class CandidateUpdateResult
+ {
+ private readonly IReadOnlyList diagnostics;
+
+ internal CandidateUpdateResult(
+ bool wasEvaluated,
+ EvaluatedTrackCandidate? candidate,
+ IEnumerable diagnostics)
+ {
+ WasEvaluated = wasEvaluated;
+ Candidate = candidate;
+ this.diagnostics = new List(
+ diagnostics ?? throw new ArgumentNullException(nameof(diagnostics))).AsReadOnly();
+ }
+
+ public bool WasEvaluated { get; }
+
+ public EvaluatedTrackCandidate? Candidate { get; }
+
+ public bool CandidateAccepted => Candidate?.IsCommitEligible == true;
+
+ public IReadOnlyList Diagnostics => diagnostics;
+ }
+
+ public sealed class AuthoringCommitResult
+ {
+ private readonly IReadOnlyList diagnostics;
+
+ internal AuthoringCommitResult(
+ bool succeeded,
+ bool changed,
+ CommittedTrackState? committedState,
+ IEnumerable diagnostics)
+ {
+ Succeeded = succeeded;
+ Changed = changed;
+ CommittedState = committedState;
+ this.diagnostics = new List(
+ diagnostics ?? throw new ArgumentNullException(nameof(diagnostics))).AsReadOnly();
+ }
+
+ public bool Succeeded { get; }
+
+ public bool Changed { get; }
+
+ public CommittedTrackState? CommittedState { get; }
+
+ public IReadOnlyList Diagnostics => diagnostics;
+ }
+
+ public sealed class AuthoringOneShotResult
+ {
+ internal AuthoringOneShotResult(
+ CandidateUpdateResult update,
+ AuthoringCommitResult? commit)
+ {
+ Update = update ?? throw new ArgumentNullException(nameof(update));
+ Commit = commit;
+ }
+
+ public CandidateUpdateResult Update { get; }
+
+ public AuthoringCommitResult? Commit { get; }
+
+ public bool Succeeded => Commit?.Succeeded == true;
+ }
+}
diff --git a/Quantum.Application/Authoring/AuthoringSessionSnapshot.cs b/Quantum.Application/Authoring/AuthoringSessionSnapshot.cs
new file mode 100644
index 0000000..56651b5
--- /dev/null
+++ b/Quantum.Application/Authoring/AuthoringSessionSnapshot.cs
@@ -0,0 +1,54 @@
+using System;
+
+namespace Quantum.Application.Authoring
+{
+ public sealed class AuthoringSessionSnapshot
+ {
+ internal AuthoringSessionSnapshot(
+ AuthoringSessionId sessionId,
+ CommittedTrackState committedState,
+ PreparedTrackGraphState presentedState,
+ InteractiveAuthoringTransaction? activeTransaction,
+ bool isDirty,
+ int undoCount,
+ int redoCount,
+ long retainedHistoryPackageByteCount)
+ {
+ SessionId = sessionId;
+ CommittedState = committedState ??
+ throw new ArgumentNullException(nameof(committedState));
+ PresentedState = presentedState ??
+ throw new ArgumentNullException(nameof(presentedState));
+ ActiveTransaction = activeTransaction;
+ IsDirty = isDirty;
+ UndoCount = undoCount;
+ RedoCount = redoCount;
+ RetainedHistoryPackageByteCount = retainedHistoryPackageByteCount;
+ }
+
+ public AuthoringSessionId SessionId { get; }
+
+ public CommittedTrackState CommittedState { get; }
+
+ public PreparedTrackGraphState PresentedState { get; }
+
+ public InteractiveAuthoringTransaction? ActiveTransaction { get; }
+
+ public bool IsPresentedCandidateCurrent =>
+ ActiveTransaction?.IsPresentedCandidateCurrent == true;
+
+ public bool IsDirty { get; }
+
+ public int UndoCount { get; }
+
+ public int RedoCount { get; }
+
+ public long RetainedHistoryPackageByteCount { get; }
+
+ ///
+ /// Persistence always reads committed content, never provisional presentation.
+ ///
+ public string? PersistableCanonicalPackageJson =>
+ CommittedState.CanonicalPackageJson;
+ }
+}
diff --git a/Quantum.Application/Authoring/CommittedTrackState.cs b/Quantum.Application/Authoring/CommittedTrackState.cs
new file mode 100644
index 0000000..20390e0
--- /dev/null
+++ b/Quantum.Application/Authoring/CommittedTrackState.cs
@@ -0,0 +1,28 @@
+using System;
+using Quantum.Track.Authoring;
+
+namespace Quantum.Application.Authoring
+{
+ public sealed class CommittedTrackState
+ {
+ internal CommittedTrackState(
+ PreparedTrackGraphState preparedState,
+ CommittedSourceRevision revision)
+ {
+ PreparedState = preparedState ??
+ throw new ArgumentNullException(nameof(preparedState));
+ Revision = revision;
+ }
+
+ public PreparedTrackGraphState PreparedState { get; }
+
+ public CommittedSourceRevision Revision { get; }
+
+ public TrackAuthoringGraph SourceGraph => PreparedState.Graph;
+
+ public TrackAuthoringGraphCompileResult? Compilation =>
+ PreparedState.GraphCompileResult;
+
+ public string? CanonicalPackageJson => PreparedState.CanonicalPackageJson;
+ }
+}
diff --git a/Quantum.Application/Authoring/EvaluatedTrackCandidate.cs b/Quantum.Application/Authoring/EvaluatedTrackCandidate.cs
new file mode 100644
index 0000000..d271c48
--- /dev/null
+++ b/Quantum.Application/Authoring/EvaluatedTrackCandidate.cs
@@ -0,0 +1,41 @@
+using System;
+using System.Collections.Generic;
+using Quantum.Track.Authoring;
+
+namespace Quantum.Application.Authoring
+{
+ public sealed class EvaluatedTrackCandidate
+ {
+ private readonly IReadOnlyList applicationDiagnostics;
+
+ internal EvaluatedTrackCandidate(
+ EvaluatedCandidateRevision revision,
+ TrackAuthoringCandidateEvaluation evaluation,
+ PreparedTrackGraphState? preparedState,
+ IEnumerable applicationDiagnostics)
+ {
+ Revision = revision;
+ Evaluation = evaluation ?? throw new ArgumentNullException(nameof(evaluation));
+ PreparedState = preparedState;
+ this.applicationDiagnostics = new List(
+ applicationDiagnostics ??
+ throw new ArgumentNullException(nameof(applicationDiagnostics))).AsReadOnly();
+ }
+
+ public EvaluatedCandidateRevision Revision { get; }
+
+ public TrackAuthoringCandidateEvaluation Evaluation { get; }
+
+ public TrackAuthoringGraph? CandidateGraph => Evaluation.CandidateGraph;
+
+ public PreparedTrackGraphState? PreparedState { get; }
+
+ public IReadOnlyList GraphDiagnostics =>
+ Evaluation.Diagnostics;
+
+ public IReadOnlyList ApplicationDiagnostics =>
+ applicationDiagnostics;
+
+ public bool IsCommitEligible => PreparedState != null;
+ }
+}
diff --git a/Quantum.Application/Authoring/InteractiveAuthoringTransaction.cs b/Quantum.Application/Authoring/InteractiveAuthoringTransaction.cs
new file mode 100644
index 0000000..f92c3d0
--- /dev/null
+++ b/Quantum.Application/Authoring/InteractiveAuthoringTransaction.cs
@@ -0,0 +1,85 @@
+using System;
+
+namespace Quantum.Application.Authoring
+{
+ public sealed class InteractiveAuthoringTransaction
+ {
+ internal InteractiveAuthoringTransaction(
+ TransactionRevision revision,
+ CommittedSourceRevision baseCommittedRevision,
+ PreparedTrackGraphState beforeState,
+ string targetSectionId,
+ string parameterIdentity,
+ string description,
+ EvaluatedTrackCandidate? newestCandidate,
+ EvaluatedTrackCandidate? presentedCandidate)
+ {
+ Revision = revision;
+ BaseCommittedRevision = baseCommittedRevision;
+ BeforeState = beforeState ?? throw new ArgumentNullException(nameof(beforeState));
+ TargetSectionId = RequireText(targetSectionId, nameof(targetSectionId));
+ ParameterIdentity = RequireText(parameterIdentity, nameof(parameterIdentity));
+ Description = RequireText(description, nameof(description));
+ NewestCandidate = newestCandidate;
+ PresentedCandidate = presentedCandidate;
+ }
+
+ public TransactionRevision Revision { get; }
+
+ public CommittedSourceRevision BaseCommittedRevision { get; }
+
+ public PreparedTrackGraphState BeforeState { get; }
+
+ public string TargetSectionId { get; }
+
+ public string ParameterIdentity { get; }
+
+ public string Description { get; }
+
+ public EvaluatedTrackCandidate? NewestCandidate { get; }
+
+ public ProvisionalEditRevision? NewestProvisionalRevision =>
+ NewestCandidate?.Revision.ProvisionalEditRevision;
+
+ ///
+ /// Last valid candidate retained for presentation. It may be older than the
+ /// newest rejected update.
+ ///
+ public EvaluatedTrackCandidate? PresentedCandidate { get; }
+
+ public bool IsPresentedCandidateCurrent =>
+ PresentedCandidate != null &&
+ NewestCandidate != null &&
+ NewestCandidate.IsCommitEligible &&
+ PresentedCandidate.Revision == NewestCandidate.Revision;
+
+ internal InteractiveAuthoringTransaction WithCandidate(
+ EvaluatedTrackCandidate candidate)
+ {
+ if (candidate is null)
+ {
+ throw new ArgumentNullException(nameof(candidate));
+ }
+
+ return new InteractiveAuthoringTransaction(
+ Revision,
+ BaseCommittedRevision,
+ BeforeState,
+ TargetSectionId,
+ ParameterIdentity,
+ Description,
+ candidate,
+ candidate.IsCommitEligible ? candidate : PresentedCandidate);
+ }
+
+ private static string RequireText(string value, string parameterName)
+ {
+ if (string.IsNullOrWhiteSpace(value))
+ {
+ throw new ArgumentException("An authoring transaction value is required.", parameterName);
+ }
+
+ return value;
+ }
+ }
+}
diff --git a/Quantum.Application/Authoring/TrackAuthoringSession.cs b/Quantum.Application/Authoring/TrackAuthoringSession.cs
new file mode 100644
index 0000000..f94f7fc
--- /dev/null
+++ b/Quantum.Application/Authoring/TrackAuthoringSession.cs
@@ -0,0 +1,398 @@
+using System;
+using System.Collections.Generic;
+using Quantum.IO.TrackLayout.V2;
+using Quantum.Track.Authoring;
+
+namespace Quantum.Application.Authoring
+{
+ ///
+ /// Headless owner of committed, presented, transactional, history, and persistence
+ /// state for one track-authoring session.
+ ///
+ public sealed class TrackAuthoringSession
+ {
+ private AuthoringSessionId sessionId;
+ private long committedRevisionSequence;
+ private long transactionRevisionSequence;
+ private CommittedTrackState committedState;
+ private PreparedTrackGraphState presentedState;
+ private InteractiveAuthoringTransaction? activeTransaction;
+ private bool hasCleanBaseline;
+ private string? cleanCanonicalPackageJson;
+
+ public TrackAuthoringSession(
+ PreparedTrackGraphState initialState,
+ bool markClean = true)
+ {
+ if (initialState is null)
+ {
+ throw new ArgumentNullException(nameof(initialState));
+ }
+
+ History = new AuthoringHistory();
+ sessionId = AuthoringSessionId.New();
+ committedState = new CommittedTrackState(
+ initialState,
+ new CommittedSourceRevision(sessionId, sequence: 0));
+ presentedState = initialState;
+ SetCleanBaseline(initialState, markClean);
+ }
+
+ public AuthoringSessionId SessionId => sessionId;
+
+ public CommittedTrackState CommittedState => committedState;
+
+ public PreparedTrackGraphState PresentedState => presentedState;
+
+ public InteractiveAuthoringTransaction? ActiveTransaction => activeTransaction;
+
+ public AuthoringHistory History { get; }
+
+ public bool HasCleanBaseline => hasCleanBaseline;
+
+ public string? CleanCanonicalPackageBaseline => cleanCanonicalPackageJson;
+
+ public string? PersistableCanonicalPackageJson =>
+ committedState.CanonicalPackageJson;
+
+ public bool IsDirty =>
+ !hasCleanBaseline ||
+ !string.Equals(
+ cleanCanonicalPackageJson,
+ committedState.CanonicalPackageJson,
+ StringComparison.Ordinal);
+
+ public static TrackAuthoringSession Create(
+ TrackAuthoringGraph graph,
+ TrackLayoutPackageV2GraphAncillaryState ancillaryState,
+ bool markClean = true)
+ {
+ return new TrackAuthoringSession(
+ PreparedTrackGraphState.Prepare(graph, ancillaryState),
+ markClean);
+ }
+
+ public AuthoringSessionSnapshot CaptureSnapshot()
+ {
+ return new AuthoringSessionSnapshot(
+ sessionId,
+ committedState,
+ presentedState,
+ activeTransaction,
+ IsDirty,
+ History.UndoCount,
+ History.RedoCount,
+ History.RetainedPackageByteCount);
+ }
+
+ public InteractiveAuthoringTransaction BeginTransaction(
+ string targetSectionId,
+ string operationDescription)
+ {
+ return BeginTransaction(
+ targetSectionId,
+ operationDescription,
+ operationDescription);
+ }
+
+ public InteractiveAuthoringTransaction BeginTransaction(
+ string targetSectionId,
+ string parameterIdentity,
+ string operationDescription)
+ {
+ if (activeTransaction != null)
+ {
+ throw new InvalidOperationException(
+ "Only one interactive authoring transaction may be active.");
+ }
+
+ transactionRevisionSequence++;
+ activeTransaction = new InteractiveAuthoringTransaction(
+ new TransactionRevision(sessionId, transactionRevisionSequence),
+ committedState.Revision,
+ committedState.PreparedState,
+ targetSectionId,
+ parameterIdentity,
+ operationDescription,
+ newestCandidate: null,
+ presentedCandidate: null);
+ return activeTransaction;
+ }
+
+ ///
+ /// Applies and evaluates an immutable absolute candidate operation against the
+ /// transaction's captured base graph. It never chains from a prior preview.
+ ///
+ public CandidateUpdateResult SubmitCandidate(
+ TransactionRevision transactionRevision,
+ ITrackAuthoringCandidateOperation operation)
+ {
+ if (operation is null)
+ {
+ throw new ArgumentNullException(nameof(operation));
+ }
+
+ if (activeTransaction is null)
+ {
+ return RejectedUpdate(
+ AuthoringSessionDiagnosticCode.NoActiveTransaction,
+ "There is no active authoring transaction.");
+ }
+
+ if (activeTransaction.Revision != transactionRevision)
+ {
+ return RejectedUpdate(
+ AuthoringSessionDiagnosticCode.TransactionRevisionMismatch,
+ "The submitted transaction revision does not match the active transaction.");
+ }
+
+ if (activeTransaction.BaseCommittedRevision != committedState.Revision)
+ {
+ return RejectedUpdate(
+ AuthoringSessionDiagnosticCode.CommittedRevisionMismatch,
+ "The transaction base no longer matches the committed source revision.");
+ }
+
+ long provisionalSequence =
+ (activeTransaction.NewestProvisionalRevision?.Sequence ?? 0) + 1;
+ var provisionalRevision = new ProvisionalEditRevision(
+ activeTransaction.Revision,
+ provisionalSequence);
+ var candidateRevision = new EvaluatedCandidateRevision(
+ activeTransaction.BaseCommittedRevision,
+ provisionalRevision);
+
+ TrackAuthoringCandidateEvaluation evaluation =
+ TrackAuthoringCandidateEvaluator.Evaluate(
+ activeTransaction.BeforeState.Graph,
+ operation);
+ var diagnostics = new List();
+ PreparedTrackGraphState? preparedState = null;
+
+ if (!evaluation.CommitEligible || evaluation.CandidateGraph is null)
+ {
+ diagnostics.Add(new AuthoringSessionDiagnostic(
+ AuthoringSessionDiagnosticCode.CandidateRejected,
+ "The candidate operation did not produce a valid authoring state."));
+ }
+ else
+ {
+ try
+ {
+ preparedState = PreparedTrackGraphState.FromEvaluation(
+ evaluation.CandidateGraph,
+ activeTransaction.BeforeState.AncillaryState,
+ evaluation.CompileResult);
+ }
+ catch (Exception exception) when (
+ exception is ArgumentException ||
+ exception is InvalidOperationException ||
+ exception is NotSupportedException)
+ {
+ diagnostics.Add(new AuthoringSessionDiagnostic(
+ AuthoringSessionDiagnosticCode.PersistencePreparationFailed,
+ exception.Message));
+ }
+ }
+
+ var candidate = new EvaluatedTrackCandidate(
+ candidateRevision,
+ evaluation,
+ preparedState,
+ diagnostics);
+ activeTransaction = activeTransaction.WithCandidate(candidate);
+ if (candidate.IsCommitEligible)
+ {
+ presentedState = candidate.PreparedState!;
+ }
+
+ return new CandidateUpdateResult(
+ wasEvaluated: true,
+ candidate,
+ diagnostics);
+ }
+
+ public AuthoringCommitResult Commit(EvaluatedCandidateRevision candidateRevision)
+ {
+ if (activeTransaction is null)
+ {
+ return RejectedCommit(
+ AuthoringSessionDiagnosticCode.NoActiveTransaction,
+ "There is no active authoring transaction.");
+ }
+
+ if (activeTransaction.BaseCommittedRevision != committedState.Revision)
+ {
+ return RejectedCommit(
+ AuthoringSessionDiagnosticCode.CommittedRevisionMismatch,
+ "The transaction base no longer matches the committed source revision.");
+ }
+
+ EvaluatedTrackCandidate? candidate = activeTransaction.NewestCandidate;
+ if (candidate is null || candidate.Revision != candidateRevision)
+ {
+ return RejectedCommit(
+ AuthoringSessionDiagnosticCode.CandidateRevisionMismatch,
+ "Only the exact newest evaluated candidate revision may be committed.");
+ }
+
+ if (!candidate.IsCommitEligible || candidate.PreparedState is null)
+ {
+ return RejectedCommit(
+ AuthoringSessionDiagnosticCode.CandidateRejected,
+ "The newest evaluated candidate is invalid and cannot be committed.");
+ }
+
+ PreparedTrackGraphState beforeState = activeTransaction.BeforeState;
+ PreparedTrackGraphState afterState = candidate.PreparedState;
+ if (beforeState.HasSameCanonicalContent(afterState))
+ {
+ activeTransaction = null;
+ presentedState = committedState.PreparedState;
+ return new AuthoringCommitResult(
+ succeeded: true,
+ changed: false,
+ committedState,
+ Array.Empty());
+ }
+
+ History.Record(new AuthoringHistoryEntry(
+ activeTransaction.Description,
+ beforeState,
+ afterState));
+ AdoptCommittedState(afterState);
+ activeTransaction = null;
+ return new AuthoringCommitResult(
+ succeeded: true,
+ changed: true,
+ committedState,
+ Array.Empty());
+ }
+
+ public bool Cancel(TransactionRevision transactionRevision)
+ {
+ if (activeTransaction is null ||
+ activeTransaction.Revision != transactionRevision)
+ {
+ return false;
+ }
+
+ activeTransaction = null;
+ presentedState = committedState.PreparedState;
+ return true;
+ }
+
+ public bool Undo()
+ {
+ if (activeTransaction != null || !History.TryUndo(out AuthoringHistoryEntry? entry))
+ {
+ return false;
+ }
+
+ AdoptCommittedState(entry!.BeforeState);
+ return true;
+ }
+
+ public bool Redo()
+ {
+ if (activeTransaction != null || !History.TryRedo(out AuthoringHistoryEntry? entry))
+ {
+ return false;
+ }
+
+ AdoptCommittedState(entry!.AfterState);
+ return true;
+ }
+
+ public void MarkClean()
+ {
+ SetCleanBaseline(committedState.PreparedState, markClean: true);
+ }
+
+ ///
+ /// Replaces the open/new session content, invalidating all prior structural
+ /// revisions and clearing transaction and history state.
+ ///
+ public void ReplaceSessionState(
+ PreparedTrackGraphState replacement,
+ bool markClean)
+ {
+ if (replacement is null)
+ {
+ throw new ArgumentNullException(nameof(replacement));
+ }
+
+ sessionId = AuthoringSessionId.New();
+ committedRevisionSequence = 0;
+ transactionRevisionSequence = 0;
+ committedState = new CommittedTrackState(
+ replacement,
+ new CommittedSourceRevision(sessionId, sequence: 0));
+ presentedState = replacement;
+ activeTransaction = null;
+ History.Clear();
+ SetCleanBaseline(replacement, markClean);
+ }
+
+ public AuthoringOneShotResult ApplyOneShot(
+ string targetSectionId,
+ string parameterIdentity,
+ string operationDescription,
+ ITrackAuthoringCandidateOperation operation)
+ {
+ InteractiveAuthoringTransaction transaction = BeginTransaction(
+ targetSectionId,
+ parameterIdentity,
+ operationDescription);
+ CandidateUpdateResult update = SubmitCandidate(transaction.Revision, operation);
+ if (!update.CandidateAccepted || update.Candidate is null)
+ {
+ Cancel(transaction.Revision);
+ return new AuthoringOneShotResult(update, commit: null);
+ }
+
+ AuthoringCommitResult commit = Commit(update.Candidate.Revision);
+ return new AuthoringOneShotResult(update, commit);
+ }
+
+ private void AdoptCommittedState(PreparedTrackGraphState preparedState)
+ {
+ committedRevisionSequence++;
+ committedState = new CommittedTrackState(
+ preparedState,
+ new CommittedSourceRevision(sessionId, committedRevisionSequence));
+ presentedState = preparedState;
+ }
+
+ private void SetCleanBaseline(
+ PreparedTrackGraphState preparedState,
+ bool markClean)
+ {
+ hasCleanBaseline = markClean;
+ cleanCanonicalPackageJson = markClean
+ ? preparedState.CanonicalPackageJson
+ : null;
+ }
+
+ private static CandidateUpdateResult RejectedUpdate(
+ AuthoringSessionDiagnosticCode code,
+ string message)
+ {
+ return new CandidateUpdateResult(
+ wasEvaluated: false,
+ candidate: null,
+ new[] { new AuthoringSessionDiagnostic(code, message) });
+ }
+
+ private static AuthoringCommitResult RejectedCommit(
+ AuthoringSessionDiagnosticCode code,
+ string message)
+ {
+ return new AuthoringCommitResult(
+ succeeded: false,
+ changed: false,
+ committedState: null,
+ new[] { new AuthoringSessionDiagnostic(code, message) });
+ }
+ }
+}
diff --git a/Quantum.Tests/Application/TrackAuthoringSessionTests.cs b/Quantum.Tests/Application/TrackAuthoringSessionTests.cs
new file mode 100644
index 0000000..e64005f
--- /dev/null
+++ b/Quantum.Tests/Application/TrackAuthoringSessionTests.cs
@@ -0,0 +1,435 @@
+using Quantum.Application.Authoring;
+using Quantum.IO.TrackLayout.V2;
+using Quantum.Track.Authoring;
+
+namespace Quantum.Tests;
+
+public sealed class TrackAuthoringSessionTests
+{
+ [Fact]
+ public void ProvisionalUpdatesKeepCommittedPersistenceDirtyAndHistoryUnchanged()
+ {
+ TrackAuthoringSession session = CreateSession();
+ CommittedTrackState committedBefore = session.CommittedState;
+ string persistedBefore = session.PersistableCanonicalPackageJson!;
+ var sources = new List();
+ InteractiveAuthoringTransaction transaction = session.BeginTransaction(
+ "turn",
+ "radius",
+ "Edit turn radius");
+
+ CandidateUpdateResult first = session.SubmitCandidate(
+ transaction.Revision,
+ new AbsoluteRadiusOperation(30.0, sources));
+ CandidateUpdateResult second = session.SubmitCandidate(
+ transaction.Revision,
+ new AbsoluteRadiusOperation(45.0, sources));
+
+ Assert.True(first.CandidateAccepted);
+ Assert.True(second.CandidateAccepted);
+ Assert.Equal(1, first.Candidate!.Revision.ProvisionalEditRevision.Sequence);
+ Assert.Equal(2, second.Candidate!.Revision.ProvisionalEditRevision.Sequence);
+ Assert.All(sources, source => Assert.Same(committedBefore.SourceGraph, source));
+ Assert.Same(committedBefore, session.CommittedState);
+ Assert.Equal(20.0, Radius(session.CommittedState.SourceGraph));
+ Assert.Equal(45.0, Radius(session.PresentedState.Graph));
+ Assert.Same(second.Candidate.PreparedState, session.PresentedState);
+ Assert.True(session.ActiveTransaction!.IsPresentedCandidateCurrent);
+ Assert.False(session.IsDirty);
+ Assert.Equal(0, session.History.UndoCount);
+ Assert.Equal(0, session.History.RedoCount);
+ Assert.Equal(persistedBefore, session.PersistableCanonicalPackageJson);
+ Assert.NotEqual(
+ persistedBefore,
+ session.PresentedState.CanonicalPackageJson);
+ }
+
+ [Fact]
+ public void InvalidNewestCandidateCannotCommitAndLeavesLastValidPreviewMarkedStale()
+ {
+ TrackAuthoringSession session = CreateSession();
+ InteractiveAuthoringTransaction transaction = session.BeginTransaction(
+ "turn",
+ "radius",
+ "Edit turn radius");
+ CandidateUpdateResult valid = session.SubmitCandidate(
+ transaction.Revision,
+ RadiusOperation(30.0));
+ PreparedTrackGraphState validPreview = session.PresentedState;
+ string persistedBefore = session.PersistableCanonicalPackageJson!;
+
+ CandidateUpdateResult invalid = session.SubmitCandidate(
+ transaction.Revision,
+ new DisconnectedRouteOperation());
+ AuthoringCommitResult commit = session.Commit(invalid.Candidate!.Revision);
+
+ Assert.True(invalid.WasEvaluated);
+ Assert.False(invalid.CandidateAccepted);
+ Assert.Equal(2, invalid.Candidate.Revision.ProvisionalEditRevision.Sequence);
+ Assert.NotEmpty(invalid.Candidate.GraphDiagnostics);
+ Assert.Contains(
+ invalid.Diagnostics,
+ diagnostic => diagnostic.Code == AuthoringSessionDiagnosticCode.CandidateRejected);
+ Assert.Same(validPreview, session.PresentedState);
+ Assert.Same(valid.Candidate!.PreparedState, session.ActiveTransaction!.PresentedCandidate!.PreparedState);
+ Assert.False(session.ActiveTransaction.IsPresentedCandidateCurrent);
+ Assert.False(commit.Succeeded);
+ Assert.Contains(
+ commit.Diagnostics,
+ diagnostic => diagnostic.Code == AuthoringSessionDiagnosticCode.CandidateRejected);
+ Assert.Equal(20.0, Radius(session.CommittedState.SourceGraph));
+ Assert.Equal(persistedBefore, session.PersistableCanonicalPackageJson);
+ Assert.False(session.IsDirty);
+ Assert.Equal(0, session.History.UndoCount);
+ }
+
+ [Fact]
+ public void CancelRestoresCommittedPresentationCreatesNoHistoryAndPreservesRedo()
+ {
+ TrackAuthoringSession session = CreateSession();
+ CommitRadius(session, 30.0);
+ Assert.True(session.Undo());
+ Assert.Equal(1, session.History.RedoCount);
+ bool dirtyBefore = session.IsDirty;
+ InteractiveAuthoringTransaction transaction = session.BeginTransaction(
+ "turn",
+ "radius",
+ "Preview turn radius");
+ Assert.True(session.SubmitCandidate(
+ transaction.Revision,
+ RadiusOperation(50.0)).CandidateAccepted);
+
+ Assert.True(session.Cancel(transaction.Revision));
+
+ Assert.Null(session.ActiveTransaction);
+ Assert.Same(session.CommittedState.PreparedState, session.PresentedState);
+ Assert.Equal(20.0, Radius(session.PresentedState.Graph));
+ Assert.Equal(0, session.History.UndoCount);
+ Assert.Equal(1, session.History.RedoCount);
+ Assert.Equal(dirtyBefore, session.IsDirty);
+ }
+
+ [Fact]
+ public void NoOpCommitCreatesNoHistoryAndDoesNotClearRedo()
+ {
+ TrackAuthoringSession session = CreateSession();
+ CommitRadius(session, 30.0);
+ Assert.True(session.Undo());
+ InteractiveAuthoringTransaction transaction = session.BeginTransaction(
+ "turn",
+ "radius",
+ "No-op turn edit");
+ CandidateUpdateResult update = session.SubmitCandidate(
+ transaction.Revision,
+ new ReturnSourceOperation());
+
+ AuthoringCommitResult commit = session.Commit(update.Candidate!.Revision);
+
+ Assert.True(commit.Succeeded);
+ Assert.False(commit.Changed);
+ Assert.Null(session.ActiveTransaction);
+ Assert.Equal(0, session.History.UndoCount);
+ Assert.Equal(1, session.History.RedoCount);
+ Assert.False(session.IsDirty);
+ }
+
+ [Fact]
+ public void ChangedCommitAdoptsExactEvaluatedStateOnceWithoutCompilingAgain()
+ {
+ TrackAuthoringSession session = CreateSession();
+ InteractiveAuthoringTransaction transaction = session.BeginTransaction(
+ "turn",
+ "radius",
+ "Edit turn radius");
+ Assert.True(session.SubmitCandidate(
+ transaction.Revision,
+ RadiusOperation(25.0)).CandidateAccepted);
+ CandidateUpdateResult update = session.SubmitCandidate(
+ transaction.Revision,
+ RadiusOperation(30.0));
+ EvaluatedTrackCandidate candidate = update.Candidate!;
+
+ using TrackAuthoringPipelineMeasurement measurement =
+ TrackAuthoringPipelineMeasurement.Begin();
+ AuthoringCommitResult commit = session.Commit(candidate.Revision);
+
+ Assert.True(commit.Succeeded);
+ Assert.True(commit.Changed);
+ Assert.Equal(0, measurement.GraphCompilerInvocationCount);
+ Assert.Equal(TimeSpan.Zero, measurement.GraphCompilerElapsed);
+ Assert.Same(candidate.PreparedState, session.CommittedState.PreparedState);
+ Assert.Same(
+ candidate.Evaluation.CompileResult,
+ session.CommittedState.Compilation);
+ Assert.Same(
+ candidate.Evaluation.CompileResult!.Compilation,
+ session.CommittedState.Compilation!.Compilation);
+ Assert.Same(
+ candidate.PreparedState!.CanonicalPackageJson,
+ session.CommittedState.CanonicalPackageJson);
+ Assert.Equal(1, session.History.UndoCount);
+ Assert.Equal(0, session.History.RedoCount);
+ Assert.True(session.History.RetainedPackageByteCount > 0);
+ Assert.True(session.IsDirty);
+ }
+
+ [Fact]
+ public void UndoAndRedoRestoreExactPreparedStatesWithoutCompilationAndTrackSavepoint()
+ {
+ TrackAuthoringSession session = CreateSession();
+ PreparedTrackGraphState before = session.CommittedState.PreparedState;
+ EvaluatedTrackCandidate candidate = CommitRadius(session, 30.0);
+ PreparedTrackGraphState after = candidate.PreparedState!;
+ Assert.True(session.IsDirty);
+ long commitRevision = session.CommittedState.Revision.Sequence;
+
+ using (TrackAuthoringPipelineMeasurement undoMeasurement =
+ TrackAuthoringPipelineMeasurement.Begin())
+ {
+ Assert.True(session.Undo());
+ Assert.Equal(0, undoMeasurement.GraphCompilerInvocationCount);
+ }
+
+ Assert.Same(before, session.CommittedState.PreparedState);
+ Assert.Equal(20.0, Radius(session.CommittedState.SourceGraph));
+ Assert.False(session.IsDirty);
+ Assert.True(session.CommittedState.Revision.Sequence > commitRevision);
+ long undoRevision = session.CommittedState.Revision.Sequence;
+
+ using (TrackAuthoringPipelineMeasurement redoMeasurement =
+ TrackAuthoringPipelineMeasurement.Begin())
+ {
+ Assert.True(session.Redo());
+ Assert.Equal(0, redoMeasurement.GraphCompilerInvocationCount);
+ }
+
+ Assert.Same(after, session.CommittedState.PreparedState);
+ Assert.Equal(30.0, Radius(session.CommittedState.SourceGraph));
+ Assert.True(session.IsDirty);
+ Assert.True(session.CommittedState.Revision.Sequence > undoRevision);
+ }
+
+ [Fact]
+ public void StaleAndMismatchedStructuralRevisionsAreRejectedSynchronously()
+ {
+ TrackAuthoringSession session = CreateSession();
+ TrackAuthoringSession otherSession = CreateSession();
+ InteractiveAuthoringTransaction transaction = session.BeginTransaction(
+ "turn",
+ "radius",
+ "Edit turn radius");
+ InteractiveAuthoringTransaction otherTransaction = otherSession.BeginTransaction(
+ "turn",
+ "radius",
+ "Other edit");
+
+ using (TrackAuthoringPipelineMeasurement measurement =
+ TrackAuthoringPipelineMeasurement.Begin())
+ {
+ CandidateUpdateResult mismatch = session.SubmitCandidate(
+ otherTransaction.Revision,
+ RadiusOperation(25.0));
+ Assert.False(mismatch.WasEvaluated);
+ Assert.Equal(0, measurement.GraphCompilerInvocationCount);
+ Assert.Contains(
+ mismatch.Diagnostics,
+ diagnostic => diagnostic.Code ==
+ AuthoringSessionDiagnosticCode.TransactionRevisionMismatch);
+ }
+
+ CandidateUpdateResult first = session.SubmitCandidate(
+ transaction.Revision,
+ RadiusOperation(30.0));
+ CandidateUpdateResult newest = session.SubmitCandidate(
+ transaction.Revision,
+ RadiusOperation(40.0));
+ AuthoringCommitResult staleCommit = session.Commit(first.Candidate!.Revision);
+
+ Assert.False(staleCommit.Succeeded);
+ Assert.Contains(
+ staleCommit.Diagnostics,
+ diagnostic => diagnostic.Code ==
+ AuthoringSessionDiagnosticCode.CandidateRevisionMismatch);
+
+ EvaluatedCandidateRevision structurallyEqual = new EvaluatedCandidateRevision(
+ newest.Candidate!.Revision.BaseCommittedRevision,
+ new ProvisionalEditRevision(
+ new TransactionRevision(
+ session.SessionId,
+ transaction.Revision.Sequence),
+ newest.Candidate.Revision.ProvisionalEditRevision.Sequence));
+ Assert.Equal(newest.Candidate.Revision, structurallyEqual);
+ Assert.True(session.Commit(structurallyEqual).Succeeded);
+ Assert.Equal(40.0, Radius(session.CommittedState.SourceGraph));
+ }
+
+ [Fact]
+ public void OneShotPathIsHeadlessAndCreatesOneAtomicHistoryEntry()
+ {
+ TrackAuthoringSession session = CreateSession();
+
+ AuthoringOneShotResult result = session.ApplyOneShot(
+ "turn",
+ "radius",
+ "Edit turn radius",
+ RadiusOperation(35.0));
+
+ Assert.True(result.Succeeded);
+ Assert.True(result.Commit!.Changed);
+ Assert.Equal(35.0, Radius(session.CommittedState.SourceGraph));
+ Assert.Equal(1, session.History.UndoCount);
+ Assert.Null(session.ActiveTransaction);
+ Assert.DoesNotContain(
+ typeof(TrackAuthoringSession).Assembly.GetReferencedAssemblies(),
+ reference => reference.Name != null &&
+ reference.Name.StartsWith("Avalonia", StringComparison.Ordinal));
+ }
+
+ [Fact]
+ public void MarkCleanAndSessionReplacementOwnDirtyAndRevisionBaselines()
+ {
+ TrackAuthoringSession session = CreateSession(markClean: false);
+ AuthoringSessionId firstSessionId = session.SessionId;
+ Assert.True(session.IsDirty);
+ session.MarkClean();
+ Assert.False(session.IsDirty);
+ CommitRadius(session, 30.0);
+ Assert.True(session.IsDirty);
+
+ PreparedTrackGraphState replacement = CreatePreparedState(radius: 50.0);
+ session.ReplaceSessionState(replacement, markClean: true);
+
+ Assert.NotEqual(firstSessionId, session.SessionId);
+ Assert.Same(replacement, session.CommittedState.PreparedState);
+ Assert.Same(replacement, session.PresentedState);
+ Assert.Equal(0, session.CommittedState.Revision.Sequence);
+ Assert.Equal(0, session.History.UndoCount);
+ Assert.False(session.IsDirty);
+ }
+
+ [Fact]
+ public void TransactionAndProvisionalRevisionSequencesIncreaseMonotonically()
+ {
+ TrackAuthoringSession session = CreateSession();
+ InteractiveAuthoringTransaction first = session.BeginTransaction(
+ "turn",
+ "radius",
+ "First edit");
+ CandidateUpdateResult invalid = session.SubmitCandidate(
+ first.Revision,
+ new DisconnectedRouteOperation());
+ CandidateUpdateResult valid = session.SubmitCandidate(
+ first.Revision,
+ RadiusOperation(25.0));
+ Assert.True(session.Cancel(first.Revision));
+ InteractiveAuthoringTransaction second = session.BeginTransaction(
+ "turn",
+ "radius",
+ "Second edit");
+
+ Assert.True(second.Revision.Sequence > first.Revision.Sequence);
+ Assert.Equal(1, invalid.Candidate!.Revision.ProvisionalEditRevision.Sequence);
+ Assert.Equal(2, valid.Candidate!.Revision.ProvisionalEditRevision.Sequence);
+ }
+
+ private static TrackAuthoringSession CreateSession(bool markClean = true)
+ {
+ return new TrackAuthoringSession(CreatePreparedState(radius: 20.0), markClean);
+ }
+
+ private static PreparedTrackGraphState CreatePreparedState(double radius)
+ {
+ var graph = new TrackAuthoringGraph(
+ new TrackAuthoringGraphNode[]
+ {
+ new TrackAuthoringGraphNode(
+ new StraightSectionDefinition("entry", 5.0)),
+ new TrackAuthoringGraphNode(
+ new ConstantCurvatureSectionDefinition("turn", 8.0, radius))
+ },
+ new[] { new TrackAuthoringGraphEdge("entry", "turn") });
+ var ancillary = new TrackLayoutPackageV2GraphAncillaryState(
+ TrackLayoutPackageV2Dto.ContractName,
+ TrackLayoutPackageV2Dto.ContractVersion,
+ "meters",
+ "M167.2 self-authored test",
+ "m167-2-test",
+ heartlineOffset: null);
+ return PreparedTrackGraphState.Prepare(graph, ancillary);
+ }
+
+ private static TrackAuthoringCandidateOperation RadiusOperation(double radius)
+ {
+ return TrackAuthoringCandidateOperation.Replace(
+ "turn",
+ new ConstantCurvatureSectionDefinition("turn", 8.0, radius));
+ }
+
+ private static EvaluatedTrackCandidate CommitRadius(
+ TrackAuthoringSession session,
+ double radius)
+ {
+ InteractiveAuthoringTransaction transaction = session.BeginTransaction(
+ "turn",
+ "radius",
+ "Edit turn radius");
+ CandidateUpdateResult update = session.SubmitCandidate(
+ transaction.Revision,
+ RadiusOperation(radius));
+ Assert.True(update.CandidateAccepted);
+ Assert.True(session.Commit(update.Candidate!.Revision).Succeeded);
+ return update.Candidate;
+ }
+
+ private static double Radius(TrackAuthoringGraph graph)
+ {
+ return Assert.IsType(
+ graph.Nodes.Single(node => node.Id == "turn").Section).Radius;
+ }
+
+ private sealed class AbsoluteRadiusOperation : ITrackAuthoringCandidateOperation
+ {
+ private readonly double radius;
+ private readonly ICollection observedSources;
+
+ public AbsoluteRadiusOperation(
+ double radius,
+ ICollection observedSources)
+ {
+ this.radius = radius;
+ this.observedSources = observedSources;
+ }
+
+ public string OperationTypeId => "test.absoluteRadius";
+
+ public TrackAuthoringGraph Apply(TrackAuthoringGraph sourceGraph)
+ {
+ observedSources.Add(sourceGraph);
+ return TrackAuthoringGraphOperations.Replace(
+ sourceGraph,
+ "turn",
+ new ConstantCurvatureSectionDefinition("turn", 8.0, radius));
+ }
+ }
+
+ private sealed class DisconnectedRouteOperation : ITrackAuthoringCandidateOperation
+ {
+ public string OperationTypeId => "test.disconnectRoute";
+
+ public TrackAuthoringGraph Apply(TrackAuthoringGraph sourceGraph)
+ {
+ return new TrackAuthoringGraph(
+ sourceGraph.Nodes,
+ Array.Empty(),
+ sourceGraph.StartPose,
+ sourceGraph.Banking);
+ }
+ }
+
+ private sealed class ReturnSourceOperation : ITrackAuthoringCandidateOperation
+ {
+ public string OperationTypeId => "test.returnSource";
+
+ public TrackAuthoringGraph Apply(TrackAuthoringGraph sourceGraph) => sourceGraph;
+ }
+}
diff --git a/Quantum.Tests/Core/BackendDependencyContractTests.cs b/Quantum.Tests/Core/BackendDependencyContractTests.cs
index 064c38b..d3a7612 100644
--- a/Quantum.Tests/Core/BackendDependencyContractTests.cs
+++ b/Quantum.Tests/Core/BackendDependencyContractTests.cs
@@ -114,6 +114,20 @@ public void QuantumBackendAssemblies_DoNotReferenceRhinoOrOpenNurbsAssemblies()
}
}
+ [Fact]
+ public void QuantumApplication_ReferencesOnlyTrackAndPersistenceQuantumAssemblies()
+ {
+ Assembly application = Assembly.Load(new AssemblyName("Quantum.Application"));
+ string[] quantumReferences = application.GetReferencedAssemblies()
+ .Select(reference => reference.Name)
+ .Where(name => name != null && name.StartsWith("Quantum.", StringComparison.Ordinal))
+ .Cast()
+ .OrderBy(name => name, StringComparer.Ordinal)
+ .ToArray();
+
+ Assert.Equal(new[] { "Quantum.IO", "Quantum.Track" }, quantumReferences);
+ }
+
[Fact]
public void QuantumBackendProjects_DoNotReferenceFrontendOrBrowserPackages()
{
diff --git a/Quantum.Tests/Track/TrackAuthoringCandidateEvaluatorTests.cs b/Quantum.Tests/Track/TrackAuthoringCandidateEvaluatorTests.cs
index 334674d..d79fea0 100644
--- a/Quantum.Tests/Track/TrackAuthoringCandidateEvaluatorTests.cs
+++ b/Quantum.Tests/Track/TrackAuthoringCandidateEvaluatorTests.cs
@@ -154,6 +154,31 @@ public void SuccessfulEvaluation_InvokesCompilerExactlyOnce()
Assert.Equal(1, compileCount);
}
+ [Fact]
+ public void EmptyCandidate_IsAValidUncompiledEditorState()
+ {
+ TrackAuthoringGraph source = Graph(new StraightSectionDefinition("a", 3.0));
+ var operation = new TestCompoundOperation(_ => Graph());
+ int compileCount = 0;
+
+ TrackAuthoringCandidateEvaluation result = TrackAuthoringCandidateEvaluator.Evaluate(
+ source,
+ operation,
+ graph =>
+ {
+ compileCount++;
+ return TrackAuthoringGraphCompiler.Compile(graph);
+ });
+
+ Assert.True(result.CommitEligible);
+ Assert.NotNull(result.CandidateGraph);
+ Assert.Empty(result.CandidateGraph!.Nodes);
+ Assert.Null(result.CompileResult);
+ Assert.Null(result.Compilation);
+ Assert.Empty(result.Diagnostics);
+ Assert.Equal(0, compileCount);
+ }
+
[Fact]
public void InvalidCandidateRoute_RetainsValidationResultAndDoesNotCompile()
{
diff --git a/Quantum.Track/Authoring/Graph/TrackAuthoringCandidateEvaluation.cs b/Quantum.Track/Authoring/Graph/TrackAuthoringCandidateEvaluation.cs
index a26b3e0..6a6b9d5 100644
--- a/Quantum.Track/Authoring/Graph/TrackAuthoringCandidateEvaluation.cs
+++ b/Quantum.Track/Authoring/Graph/TrackAuthoringCandidateEvaluation.cs
@@ -45,8 +45,8 @@ internal TrackAuthoringCandidateEvaluation(
public bool CommitEligible =>
CandidateGraph != null &&
RouteResult?.Success == true &&
- CompileResult?.Success == true &&
- Compilation != null;
+ (CandidateGraph.Nodes.Count == 0 ||
+ (CompileResult?.Success == true && Compilation != null));
///
/// Conservatively detects whether the active immutable graph snapshot changed
diff --git a/Quantum.Track/Authoring/Graph/TrackAuthoringCandidateEvaluator.cs b/Quantum.Track/Authoring/Graph/TrackAuthoringCandidateEvaluator.cs
index 2200b13..0048c7f 100644
--- a/Quantum.Track/Authoring/Graph/TrackAuthoringCandidateEvaluator.cs
+++ b/Quantum.Track/Authoring/Graph/TrackAuthoringCandidateEvaluator.cs
@@ -72,6 +72,19 @@ exception is InvalidOperationException ||
routeResult.Diagnostics);
}
+ // Empty is a valid editor/session state even though it is intentionally not
+ // a compilable or persistable track package.
+ if (candidateGraph.Nodes.Count == 0)
+ {
+ return new TrackAuthoringCandidateEvaluation(
+ sourceGraph,
+ operation,
+ candidateGraph,
+ routeResult,
+ null,
+ Array.Empty());
+ }
+
TrackAuthoringGraphCompileResult compileResult = compiler(candidateGraph);
IReadOnlyList diagnostics = compileResult.Diagnostics;
return new TrackAuthoringCandidateEvaluation(
From 02a278fc02bcd15c86d17f02835493ec85e5818a Mon Sep 17 00:00:00 2001
From: Coasterpete <83011124+Coasterpete@users.noreply.github.com>
Date: Wed, 22 Jul 2026 15:48:32 -0400
Subject: [PATCH 05/11] docs: describe M167.2 session ownership
---
...2-frontend-neutral-session-transactions.md | 136 ++++++++++++++++++
structure.md | 1 +
2 files changed, 137 insertions(+)
create mode 100644 docs/editor/m167-2-frontend-neutral-session-transactions.md
diff --git a/docs/editor/m167-2-frontend-neutral-session-transactions.md b/docs/editor/m167-2-frontend-neutral-session-transactions.md
new file mode 100644
index 0000000..3d09478
--- /dev/null
+++ b/docs/editor/m167-2-frontend-neutral-session-transactions.md
@@ -0,0 +1,136 @@
+# M167.2 Frontend-Neutral Session, Transaction, and Revision Model
+
+## Scope
+
+M167.2 adds a synchronous, headless authoring-session boundary. It does not add
+scheduling, background work, throttling, pointer gestures, live viewport behavior,
+incremental compilation, or a broad `EditorWorkspace` migration.
+
+`Quantum.Application` targets `netstandard2.1` and directly references only:
+
+- `Quantum.Track` for immutable graph operations, validation, and compilation;
+- `Quantum.IO` for canonical Track Layout Package V2 preparation.
+
+Dependency-contract tests reject Avalonia, Unity, Unreal, renderer, browser, and
+windowing dependencies. Geometry algorithms and section semantics remain in
+`Quantum.Track`; persistence mapping remains in `Quantum.IO`.
+
+## Ownership before and after
+
+Before M167.2, M167.1's prepared source/compilation/JSON state and prepared-state
+undo retention lived inside `Quantum.Editor.Avalonia`. That removed redundant work
+from the current editor, but another frontend could not reuse the ownership model.
+
+After M167.2:
+
+- `PreparedTrackGraphState` is the shared immutable source graph, exact compile
+ result, ancillary persistence state, and canonical package JSON tuple;
+- `TrackAuthoringSession` owns the committed state, presented state, active
+ transaction, structural revisions, dirty baseline, and authoring history;
+- `InteractiveAuthoringTransaction` captures the exact before-state, base committed
+ revision, stable target section ID, and parameter/operation identity;
+- `EvaluatedTrackCandidate` retains the exact candidate graph, evaluation,
+ compilation, canonical JSON, and structured diagnostics;
+- `AuthoringHistory` retains exact prepared before/after states and exposes retained
+ canonical JSON byte size without introducing eviction policy; and
+- Avalonia reuses `PreparedTrackGraphState`, but its existing one-shot workspace and
+ generic undo integration otherwise remain unchanged.
+
+The session's ownership rules are:
+
+| Operation | Committed state | Presented state | Normal history | Dirty state | Persistable content |
+|---|---|---|---|---|---|
+| Begin | unchanged | committed | unchanged | unchanged | committed |
+| Valid update | unchanged | newest valid candidate | unchanged | unchanged | committed |
+| Invalid update | unchanged | last valid candidate, if any | unchanged | unchanged | committed |
+| Cancel | unchanged | committed | unchanged, including redo | unchanged | committed |
+| Changed commit | exact evaluated candidate | committed candidate | one undo entry; redo cleared | recomputed | committed candidate |
+| No-op commit | unchanged | committed | unchanged, including redo | unchanged | committed |
+| Undo | exact retained before-state | committed before-state | entry moves to redo | recomputed | committed before-state |
+| Redo | exact retained after-state | committed after-state | entry moves to undo | recomputed | committed after-state |
+
+## Structural revisions
+
+The application stale/current decisions use immutable structural value identities:
+
+- `AuthoringSessionId`;
+- `CommittedSourceRevision`;
+- `TransactionRevision`;
+- `ProvisionalEditRevision`;
+- `EvaluatedCandidateRevision`.
+
+Committed, transaction, and provisional sequences are monotonic within their
+scopes. An evaluated candidate revision combines the captured committed base
+and provisional revision. Update and commit compare these values structurally;
+object-reference equality is used only by the M167.1 exact compile-result ownership
+guard, not for transaction freshness.
+
+Every matching valid or invalid update advances the provisional revision. A
+mismatched transaction is rejected before evaluation. A commit accepts only the
+exact newest structurally matching valid revision. Consequently, an older valid
+preview cannot commit after a newer invalid update.
+
+## Synchronous transaction path
+
+The reusable interactive path is:
+
+1. `BeginTransaction` captures the committed revision and exact prepared before-state.
+2. `SubmitCandidate` applies an immutable absolute candidate operation to that
+ captured base graph, never to a preceding provisional graph.
+3. `TrackAuthoringCandidateEvaluator` validates and compiles synchronously.
+4. A successful result is prepared through the compile-result-aware V2 export path.
+5. `Commit` adopts that exact prepared candidate without reevaluation.
+6. `Cancel`, `Undo`, and `Redo` only switch retained prepared states.
+
+`ApplyOneShot` provides the same begin, one evaluation, and commit sequence for a
+future non-interactive caller migration. M166 `EditorWorkspace.ApplyGraphEdit` is not
+migrated in this milestone.
+
+An empty candidate remains a valid editor/session state. It has no compilation and
+no persistable package JSON, matching the existing empty-route editor behavior.
+
+## Persistence and dirty state
+
+`PersistableCanonicalPackageJson` always comes from `CommittedState`. A provisional
+preview therefore cannot leak into Save. `MarkClean` captures the current committed
+canonical JSON as the savepoint; `ReplaceSessionState` establishes open/new content,
+invalidates previous session revisions, clears history, and chooses a clean or dirty
+baseline.
+
+Dirty state changes only after changed commit, undo, redo, mark-clean, or session
+replacement. Returning by undo to the byte-identical clean canonical content reports
+clean.
+
+## Compile counts
+
+Scoped pipeline instrumentation and focused tests establish:
+
+| Session operation | Graph compiler invocations |
+|---|---:|
+| Non-empty candidate update | 1 |
+| Empty candidate update | 0 |
+| Commit of evaluated candidate | 0 |
+| Undo | 0 |
+| Redo | 0 |
+
+Commit also reuses the exact candidate compile-result instance and canonical JSON
+instance. Undo and redo restore the exact retained prepared states.
+
+## Verification
+
+The M167.2 tests cover committed/presented separation, base-derived updates,
+monotonic valid and invalid revisions, structured rejection, stale structural
+revision rejection, cancel and redo retention, no-op and changed commits, exact
+candidate adoption, savepoint cleanliness, one-entry history, zero-compilation
+commit/undo/redo, one-shot editing, empty candidates, retained-size exposure, and
+frontend dependency contracts.
+
+The full solution test run passes 1,887 tests with zero failures and zero skips.
+
+## M167.3 entry point
+
+M167.3 should begin with a thin scheduling coordinator above
+`TrackAuthoringSession`. It should retain the session's structural transaction and
+provisional revisions as the acceptance authority while adding latest-pending work
+policy. The session remains the owner of committed, presented, dirty, persistence,
+and history state. No M167.3 scheduling behavior is implemented here.
diff --git a/structure.md b/structure.md
index fd45631..2b4b610 100644
--- a/structure.md
+++ b/structure.md
@@ -21,6 +21,7 @@ QuantumCoasterWorks.sln
- Quantum.FVD = force vector design systems
- Quantum.IO = save/load/import/export
- Quantum.Debug = diagnostics
+- Quantum.Application = frontend-neutral authoring session, transaction, revision, history, dirty-state, and canonical persistence orchestration
- Quantum.Editor.Avalonia = standalone Avalonia technical editor and Track Layout Package V2 workspace
## Frontend / Host Direction
From 95d6eefc48ad1c80b3dc6982235172cb7d868e9d Mon Sep 17 00:00:00 2001
From: Coasterpete <83011124+Coasterpete@users.noreply.github.com>
Date: Wed, 22 Jul 2026 16:29:36 -0400
Subject: [PATCH 06/11] feat(application): add latest-wins authoring evaluation
scheduling
---
.../AuthoringEvaluationScheduling.cs | 351 ++++++++
.../InteractiveAuthoringTransaction.cs | 37 +-
.../TrackAuthoringEvaluationCoordinator.cs | 754 ++++++++++++++++++
.../Authoring/TrackAuthoringSession.cs | 519 ++++++++----
.../TrackCandidateEvaluationServices.cs | 198 +++++
.../Quantum.Application.csproj | 6 +
...ngEvaluationCoordinatorIntegrationTests.cs | 369 +++++++++
...rackAuthoringEvaluationCoordinatorTests.cs | 707 ++++++++++++++++
.../TrackAuthoringCandidateEvaluation.cs | 10 +-
.../Graph/TrackAuthoringCandidateEvaluator.cs | 25 +-
10 files changed, 2800 insertions(+), 176 deletions(-)
create mode 100644 Quantum.Application/Authoring/AuthoringEvaluationScheduling.cs
create mode 100644 Quantum.Application/Authoring/TrackAuthoringEvaluationCoordinator.cs
create mode 100644 Quantum.Application/Authoring/TrackCandidateEvaluationServices.cs
create mode 100644 Quantum.Tests/Application/TrackAuthoringEvaluationCoordinatorIntegrationTests.cs
create mode 100644 Quantum.Tests/Application/TrackAuthoringEvaluationCoordinatorTests.cs
diff --git a/Quantum.Application/Authoring/AuthoringEvaluationScheduling.cs b/Quantum.Application/Authoring/AuthoringEvaluationScheduling.cs
new file mode 100644
index 0000000..8ce932a
--- /dev/null
+++ b/Quantum.Application/Authoring/AuthoringEvaluationScheduling.cs
@@ -0,0 +1,351 @@
+using System;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using Quantum.IO.TrackLayout.V2;
+using Quantum.Track.Authoring;
+
+namespace Quantum.Application.Authoring
+{
+ public enum AuthoringEvaluationExecutionMode
+ {
+ Synchronous = 0,
+ SerializedBackground = 1
+ }
+
+ public enum AuthoringEvaluationOutcomeStatus
+ {
+ Accepted = 0,
+ Rejected = 1,
+ Stale = 2,
+ Coalesced = 3,
+ Cancelled = 4,
+ Faulted = 5
+ }
+
+ public enum AuthoringEvaluationPhase
+ {
+ CandidateConstruction = 0,
+ CandidateEvaluation = 1,
+ PackagePreparation = 2,
+ Publication = 3,
+ Scheduling = 4
+ }
+
+ public readonly struct EvaluationRequestRevision :
+ IEquatable
+ {
+ public EvaluationRequestRevision(AuthoringSessionId sessionId, long sequence)
+ {
+ if (sessionId.Value == Guid.Empty)
+ {
+ throw new ArgumentException(
+ "An evaluation request revision requires a session ID.",
+ nameof(sessionId));
+ }
+
+ if (sequence <= 0)
+ {
+ throw new ArgumentOutOfRangeException(nameof(sequence));
+ }
+
+ SessionId = sessionId;
+ Sequence = sequence;
+ }
+
+ public AuthoringSessionId SessionId { get; }
+
+ public long Sequence { get; }
+
+ public bool Equals(EvaluationRequestRevision other) =>
+ SessionId.Equals(other.SessionId) && Sequence == other.Sequence;
+
+ public override bool Equals(object? obj) =>
+ obj is EvaluationRequestRevision && Equals((EvaluationRequestRevision)obj);
+
+ public override int GetHashCode() =>
+ unchecked((SessionId.GetHashCode() * 397) ^ Sequence.GetHashCode());
+
+ public override string ToString() => $"{SessionId}:evaluation:{Sequence}";
+
+ public static bool operator ==(
+ EvaluationRequestRevision left,
+ EvaluationRequestRevision right) => left.Equals(right);
+
+ public static bool operator !=(
+ EvaluationRequestRevision left,
+ EvaluationRequestRevision right) => !left.Equals(right);
+ }
+
+ public sealed class AuthoringEvaluationSchedulerOptions
+ {
+ public AuthoringEvaluationSchedulerOptions(
+ AuthoringEvaluationExecutionMode executionMode,
+ TimeSpan minimumEvaluationInterval)
+ {
+ if (minimumEvaluationInterval < TimeSpan.Zero)
+ {
+ throw new ArgumentOutOfRangeException(nameof(minimumEvaluationInterval));
+ }
+
+ ExecutionMode = executionMode;
+ MinimumEvaluationInterval = minimumEvaluationInterval;
+ }
+
+ public AuthoringEvaluationExecutionMode ExecutionMode { get; }
+
+ public TimeSpan MinimumEvaluationInterval { get; }
+
+ public static AuthoringEvaluationSchedulerOptions Synchronous { get; } =
+ new AuthoringEvaluationSchedulerOptions(
+ AuthoringEvaluationExecutionMode.Synchronous,
+ TimeSpan.Zero);
+
+ public static AuthoringEvaluationSchedulerOptions SerializedBackground { get; } =
+ new AuthoringEvaluationSchedulerOptions(
+ AuthoringEvaluationExecutionMode.SerializedBackground,
+ TimeSpan.FromSeconds(1.0 / 30.0));
+ }
+
+ public sealed class AuthoringEvaluationRequest
+ {
+ internal AuthoringEvaluationRequest(
+ EvaluationRequestRevision requestRevision,
+ EvaluatedCandidateRevision candidateRevision,
+ TrackAuthoringGraph sourceGraph,
+ TrackLayoutPackageV2GraphAncillaryState ancillaryState,
+ ITrackAuthoringCandidateOperation operation,
+ DateTimeOffset submittedAtUtc)
+ {
+ RequestRevision = requestRevision;
+ CandidateRevision = candidateRevision;
+ SourceGraph = sourceGraph ?? throw new ArgumentNullException(nameof(sourceGraph));
+ AncillaryState = ancillaryState ??
+ throw new ArgumentNullException(nameof(ancillaryState));
+ Operation = operation ?? throw new ArgumentNullException(nameof(operation));
+ SubmittedAtUtc = submittedAtUtc;
+ }
+
+ public EvaluationRequestRevision RequestRevision { get; }
+
+ public EvaluatedCandidateRevision CandidateRevision { get; }
+
+ public AuthoringSessionId SessionId => RequestRevision.SessionId;
+
+ public CommittedSourceRevision BaseCommittedRevision =>
+ CandidateRevision.BaseCommittedRevision;
+
+ public TransactionRevision TransactionRevision =>
+ CandidateRevision.ProvisionalEditRevision.TransactionRevision;
+
+ public ProvisionalEditRevision ProvisionalEditRevision =>
+ CandidateRevision.ProvisionalEditRevision;
+
+ public TrackAuthoringGraph SourceGraph { get; }
+
+ public TrackLayoutPackageV2GraphAncillaryState AncillaryState { get; }
+
+ public ITrackAuthoringCandidateOperation Operation { get; }
+
+ public DateTimeOffset SubmittedAtUtc { get; }
+ }
+
+ public sealed class AuthoringEvaluationTiming
+ {
+ internal AuthoringEvaluationTiming(
+ TimeSpan queueWaitTime,
+ TimeSpan candidateApplicationTime,
+ TimeSpan validationAndCompilationTime,
+ TimeSpan packagePreparationTime,
+ TimeSpan totalEvaluationTime,
+ TimeSpan submitToResultTime,
+ TimeSpan? submitToPresentTime,
+ int compilerInvocationCount)
+ {
+ QueueWaitTime = queueWaitTime;
+ CandidateApplicationTime = candidateApplicationTime;
+ ValidationAndCompilationTime = validationAndCompilationTime;
+ PackagePreparationTime = packagePreparationTime;
+ TotalEvaluationTime = totalEvaluationTime;
+ SubmitToResultTime = submitToResultTime;
+ SubmitToPresentTime = submitToPresentTime;
+ CompilerInvocationCount = compilerInvocationCount;
+ }
+
+ public TimeSpan QueueWaitTime { get; }
+
+ public TimeSpan CandidateApplicationTime { get; }
+
+ public TimeSpan ValidationAndCompilationTime { get; }
+
+ public TimeSpan PackagePreparationTime { get; }
+
+ public TimeSpan TotalEvaluationTime { get; }
+
+ public TimeSpan SubmitToResultTime { get; }
+
+ public TimeSpan? SubmitToPresentTime { get; }
+
+ public int CompilerInvocationCount { get; }
+ }
+
+ public sealed class AuthoringEvaluationFault
+ {
+ internal AuthoringEvaluationFault(
+ AuthoringEvaluationPhase phase,
+ Exception exception)
+ {
+ Phase = phase;
+ ExceptionType = exception.GetType().FullName ?? exception.GetType().Name;
+ Message = exception.Message;
+ }
+
+ public AuthoringEvaluationPhase Phase { get; }
+
+ public string ExceptionType { get; }
+
+ public string Message { get; }
+ }
+
+ public sealed class AuthoringEvaluationOutcome
+ {
+ private readonly IReadOnlyList diagnostics;
+
+ internal AuthoringEvaluationOutcome(
+ AuthoringEvaluationRequest request,
+ AuthoringEvaluationOutcomeStatus status,
+ EvaluatedTrackCandidate? candidate,
+ IEnumerable diagnostics,
+ AuthoringEvaluationTiming timing,
+ AuthoringEvaluationFault? fault)
+ {
+ Request = request ?? throw new ArgumentNullException(nameof(request));
+ Status = status;
+ Candidate = candidate;
+ this.diagnostics = new List(
+ diagnostics ?? throw new ArgumentNullException(nameof(diagnostics))).AsReadOnly();
+ Timing = timing ?? throw new ArgumentNullException(nameof(timing));
+ Fault = fault;
+ }
+
+ public AuthoringEvaluationRequest Request { get; }
+
+ public AuthoringEvaluationOutcomeStatus Status { get; }
+
+ public EvaluatedTrackCandidate? Candidate { get; }
+
+ public IReadOnlyList Diagnostics => diagnostics;
+
+ public AuthoringEvaluationTiming Timing { get; }
+
+ public AuthoringEvaluationFault? Fault { get; }
+
+ public bool WasPresented => Timing.SubmitToPresentTime.HasValue;
+ }
+
+ public sealed class AuthoringEvaluationSubmission
+ {
+ internal AuthoringEvaluationSubmission(
+ AuthoringEvaluationRequest request,
+ Task completion)
+ {
+ Request = request ?? throw new ArgumentNullException(nameof(request));
+ Completion = completion ?? throw new ArgumentNullException(nameof(completion));
+ }
+
+ public AuthoringEvaluationRequest Request { get; }
+
+ public Task Completion { get; }
+ }
+
+ public sealed class AuthoringScheduledCommitResult
+ {
+ internal AuthoringScheduledCommitResult(
+ EvaluatedCandidateRevision candidateRevision,
+ AuthoringEvaluationOutcomeStatus evaluationStatus,
+ AuthoringCommitResult? commitResult)
+ {
+ CandidateRevision = candidateRevision;
+ EvaluationStatus = evaluationStatus;
+ CommitResult = commitResult;
+ }
+
+ public EvaluatedCandidateRevision CandidateRevision { get; }
+
+ public AuthoringEvaluationOutcomeStatus EvaluationStatus { get; }
+
+ public AuthoringCommitResult? CommitResult { get; }
+
+ public bool Succeeded => CommitResult?.Succeeded == true;
+ }
+
+ public sealed class EvaluationSchedulerSnapshot
+ {
+ internal EvaluationSchedulerSnapshot(
+ long submitted,
+ long started,
+ long completed,
+ long accepted,
+ long rejected,
+ long stale,
+ long coalesced,
+ long cancelledBeforeStart,
+ long cancelled,
+ long faulted,
+ int maximumPendingDepth,
+ int pendingDepth,
+ bool isEvaluationRunning,
+ TimeSpan queueWaitTime,
+ TimeSpan candidateApplicationTime,
+ TimeSpan validationAndCompilationTime,
+ TimeSpan packagePreparationTime,
+ TimeSpan totalEvaluationTime,
+ TimeSpan submitToResultTime,
+ TimeSpan submitToPresentTime,
+ int compilerInvocationCount)
+ {
+ Submitted = submitted;
+ Started = started;
+ Completed = completed;
+ Accepted = accepted;
+ Rejected = rejected;
+ Stale = stale;
+ Coalesced = coalesced;
+ CancelledBeforeStart = cancelledBeforeStart;
+ Cancelled = cancelled;
+ Faulted = faulted;
+ MaximumPendingDepth = maximumPendingDepth;
+ PendingDepth = pendingDepth;
+ IsEvaluationRunning = isEvaluationRunning;
+ QueueWaitTime = queueWaitTime;
+ CandidateApplicationTime = candidateApplicationTime;
+ ValidationAndCompilationTime = validationAndCompilationTime;
+ PackagePreparationTime = packagePreparationTime;
+ TotalEvaluationTime = totalEvaluationTime;
+ SubmitToResultTime = submitToResultTime;
+ SubmitToPresentTime = submitToPresentTime;
+ CompilerInvocationCount = compilerInvocationCount;
+ }
+
+ public long Submitted { get; }
+ public long Started { get; }
+ public long Completed { get; }
+ public long Accepted { get; }
+ public long Rejected { get; }
+ public long Stale { get; }
+ public long Coalesced { get; }
+ public long CancelledBeforeStart { get; }
+ public long Cancelled { get; }
+ public long Faulted { get; }
+ public int MaximumPendingDepth { get; }
+ public int PendingDepth { get; }
+ public bool IsEvaluationRunning { get; }
+ public TimeSpan QueueWaitTime { get; }
+ public TimeSpan CandidateApplicationTime { get; }
+ public TimeSpan ValidationAndCompilationTime { get; }
+ public TimeSpan PackagePreparationTime { get; }
+ public TimeSpan TotalEvaluationTime { get; }
+ public TimeSpan SubmitToResultTime { get; }
+ public TimeSpan SubmitToPresentTime { get; }
+ public int CompilerInvocationCount { get; }
+ }
+}
diff --git a/Quantum.Application/Authoring/InteractiveAuthoringTransaction.cs b/Quantum.Application/Authoring/InteractiveAuthoringTransaction.cs
index f92c3d0..ec06ebe 100644
--- a/Quantum.Application/Authoring/InteractiveAuthoringTransaction.cs
+++ b/Quantum.Application/Authoring/InteractiveAuthoringTransaction.cs
@@ -11,6 +11,7 @@ internal InteractiveAuthoringTransaction(
string targetSectionId,
string parameterIdentity,
string description,
+ ProvisionalEditRevision? newestProvisionalRevision,
EvaluatedTrackCandidate? newestCandidate,
EvaluatedTrackCandidate? presentedCandidate)
{
@@ -20,6 +21,7 @@ internal InteractiveAuthoringTransaction(
TargetSectionId = RequireText(targetSectionId, nameof(targetSectionId));
ParameterIdentity = RequireText(parameterIdentity, nameof(parameterIdentity));
Description = RequireText(description, nameof(description));
+ NewestProvisionalRevision = newestProvisionalRevision;
NewestCandidate = newestCandidate;
PresentedCandidate = presentedCandidate;
}
@@ -38,8 +40,7 @@ internal InteractiveAuthoringTransaction(
public EvaluatedTrackCandidate? NewestCandidate { get; }
- public ProvisionalEditRevision? NewestProvisionalRevision =>
- NewestCandidate?.Revision.ProvisionalEditRevision;
+ public ProvisionalEditRevision? NewestProvisionalRevision { get; }
///
/// Last valid candidate retained for presentation. It may be older than the
@@ -50,9 +51,33 @@ internal InteractiveAuthoringTransaction(
public bool IsPresentedCandidateCurrent =>
PresentedCandidate != null &&
NewestCandidate != null &&
+ NewestProvisionalRevision.HasValue &&
NewestCandidate.IsCommitEligible &&
+ NewestCandidate.Revision.ProvisionalEditRevision == NewestProvisionalRevision.Value &&
PresentedCandidate.Revision == NewestCandidate.Revision;
+ internal InteractiveAuthoringTransaction WithReservedProvisionalRevision(
+ ProvisionalEditRevision provisionalRevision)
+ {
+ if (provisionalRevision.TransactionRevision != Revision)
+ {
+ throw new ArgumentException(
+ "A provisional revision must belong to this transaction.",
+ nameof(provisionalRevision));
+ }
+
+ return new InteractiveAuthoringTransaction(
+ Revision,
+ BaseCommittedRevision,
+ BeforeState,
+ TargetSectionId,
+ ParameterIdentity,
+ Description,
+ provisionalRevision,
+ NewestCandidate,
+ PresentedCandidate);
+ }
+
internal InteractiveAuthoringTransaction WithCandidate(
EvaluatedTrackCandidate candidate)
{
@@ -61,6 +86,13 @@ internal InteractiveAuthoringTransaction WithCandidate(
throw new ArgumentNullException(nameof(candidate));
}
+ if (!NewestProvisionalRevision.HasValue ||
+ candidate.Revision.ProvisionalEditRevision != NewestProvisionalRevision.Value)
+ {
+ throw new InvalidOperationException(
+ "Only the newest reserved provisional revision may be adopted.");
+ }
+
return new InteractiveAuthoringTransaction(
Revision,
BaseCommittedRevision,
@@ -68,6 +100,7 @@ internal InteractiveAuthoringTransaction WithCandidate(
TargetSectionId,
ParameterIdentity,
Description,
+ NewestProvisionalRevision,
candidate,
candidate.IsCommitEligible ? candidate : PresentedCandidate);
}
diff --git a/Quantum.Application/Authoring/TrackAuthoringEvaluationCoordinator.cs b/Quantum.Application/Authoring/TrackAuthoringEvaluationCoordinator.cs
new file mode 100644
index 0000000..576df04
--- /dev/null
+++ b/Quantum.Application/Authoring/TrackAuthoringEvaluationCoordinator.cs
@@ -0,0 +1,754 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Quantum.Application.Authoring
+{
+ ///
+ /// Frontend-neutral one-running/one-latest-pending evaluation coordinator.
+ /// TrackAuthoringSession remains the final authority for candidate publication
+ /// and commit.
+ ///
+ public sealed class TrackAuthoringEvaluationCoordinator : IAsyncDisposable, IDisposable
+ {
+ private readonly object gate = new object();
+ private readonly TrackAuthoringSession session;
+ private readonly AuthoringEvaluationSchedulerOptions options;
+ private readonly ITrackCandidateEvaluator evaluator;
+ private readonly IAuthoringEvaluationClock clock;
+ private readonly SemaphoreSlim workSignal = new SemaphoreSlim(0);
+ private readonly CancellationTokenSource shutdown = new CancellationTokenSource();
+ private readonly Dictionary outstanding =
+ new Dictionary();
+ private readonly Task? workerTask;
+
+ private WorkItem? pending;
+ private WorkItem? running;
+ private DateTimeOffset? lastEvaluationStartedAtUtc;
+ private long requestSequence;
+ private bool disposed;
+ private TransactionRevision? frozenCommitTransaction;
+ private ProvisionalEditRevision? frozenCommitRevision;
+
+ private long submitted;
+ private long started;
+ private long completed;
+ private long accepted;
+ private long rejected;
+ private long stale;
+ private long coalesced;
+ private long cancelledBeforeStart;
+ private long cancelled;
+ private long faulted;
+ private int maximumPendingDepth;
+ private TimeSpan queueWaitTime;
+ private TimeSpan candidateApplicationTime;
+ private TimeSpan validationAndCompilationTime;
+ private TimeSpan packagePreparationTime;
+ private TimeSpan totalEvaluationTime;
+ private TimeSpan submitToResultTime;
+ private TimeSpan submitToPresentTime;
+ private int compilerInvocationCount;
+
+ public TrackAuthoringEvaluationCoordinator(TrackAuthoringSession session)
+ : this(
+ session,
+ AuthoringEvaluationSchedulerOptions.Synchronous,
+ new ProductionTrackCandidateEvaluator(),
+ SystemAuthoringEvaluationClock.Instance)
+ {
+ }
+
+ public TrackAuthoringEvaluationCoordinator(
+ TrackAuthoringSession session,
+ AuthoringEvaluationSchedulerOptions options)
+ : this(
+ session,
+ options,
+ new ProductionTrackCandidateEvaluator(),
+ SystemAuthoringEvaluationClock.Instance)
+ {
+ }
+
+ internal TrackAuthoringEvaluationCoordinator(
+ TrackAuthoringSession session,
+ AuthoringEvaluationSchedulerOptions options,
+ ITrackCandidateEvaluator evaluator,
+ IAuthoringEvaluationClock clock)
+ {
+ this.session = session ?? throw new ArgumentNullException(nameof(session));
+ this.options = options ?? throw new ArgumentNullException(nameof(options));
+ this.evaluator = evaluator ?? throw new ArgumentNullException(nameof(evaluator));
+ this.clock = clock ?? throw new ArgumentNullException(nameof(clock));
+
+ if (options.ExecutionMode ==
+ AuthoringEvaluationExecutionMode.SerializedBackground)
+ {
+ workerTask = Task.Run(WorkerLoopAsync);
+ }
+ }
+
+ public AuthoringEvaluationSchedulerOptions Options => options;
+
+ public AuthoringEvaluationSubmission SubmitProvisionalEdit(
+ TransactionRevision transactionRevision,
+ Quantum.Track.Authoring.ITrackAuthoringCandidateOperation operation,
+ CancellationToken cancellationToken = default)
+ {
+ if (operation is null)
+ {
+ throw new ArgumentNullException(nameof(operation));
+ }
+
+ cancellationToken.ThrowIfCancellationRequested();
+ WorkItem item;
+ lock (gate)
+ {
+ ThrowIfDisposed();
+ if (frozenCommitTransaction.HasValue &&
+ frozenCommitTransaction.Value == transactionRevision)
+ {
+ throw new InvalidOperationException(
+ "The newest provisional revision is frozen for commit.");
+ }
+
+ requestSequence++;
+ AuthoringSessionId currentSessionId = session.SessionId;
+ var requestRevision = new EvaluationRequestRevision(
+ currentSessionId,
+ requestSequence);
+ AuthoringCandidateReservationResult reservation =
+ session.ReserveCandidate(
+ transactionRevision,
+ operation,
+ requestRevision,
+ clock.UtcNow);
+ if (reservation.Request is null)
+ {
+ string message = reservation.Diagnostics.Count == 0
+ ? "The provisional edit could not be reserved."
+ : reservation.Diagnostics[0].Message;
+ throw new InvalidOperationException(message);
+ }
+
+ item = new WorkItem(
+ reservation.Request,
+ CancellationTokenSource.CreateLinkedTokenSource(
+ shutdown.Token,
+ cancellationToken));
+ submitted++;
+ outstanding.Add(item.Request.ProvisionalEditRevision, item);
+
+ if (options.ExecutionMode ==
+ AuthoringEvaluationExecutionMode.SerializedBackground)
+ {
+ if (pending != null)
+ {
+ WorkItem replaced = pending;
+ pending = null;
+ outstanding.Remove(replaced.Request.ProvisionalEditRevision);
+ replaced.Cancellation.Cancel();
+ CompleteWithoutStartCore(
+ replaced,
+ AuthoringEvaluationOutcomeStatus.Coalesced,
+ countAsCoalesced: true);
+ }
+
+ pending = item;
+ maximumPendingDepth = System.Math.Max(maximumPendingDepth, 1);
+ workSignal.Release();
+ }
+ else
+ {
+ running = item;
+ started++;
+ item.StartedAtUtc = clock.UtcNow;
+ lastEvaluationStartedAtUtc = item.StartedAtUtc;
+ }
+ }
+
+ if (options.ExecutionMode == AuthoringEvaluationExecutionMode.Synchronous)
+ {
+ EvaluateAndPublishAsync(item).GetAwaiter().GetResult();
+ lock (gate)
+ {
+ if (ReferenceEquals(running, item))
+ {
+ running = null;
+ }
+
+ outstanding.Remove(item.Request.ProvisionalEditRevision);
+ }
+ }
+
+ return new AuthoringEvaluationSubmission(item.Request, item.Completion.Task);
+ }
+
+ public async Task CommitLatestAsync(
+ TransactionRevision transactionRevision,
+ CancellationToken cancellationToken = default)
+ {
+ EvaluatedCandidateRevision newestCandidateRevision;
+ ProvisionalEditRevision provisionalRevision;
+ EvaluatedTrackCandidate? candidate;
+ WorkItem? work = null;
+ lock (gate)
+ {
+ ThrowIfDisposed();
+ if (frozenCommitTransaction.HasValue)
+ {
+ throw new InvalidOperationException(
+ "A final provisional revision is already waiting to commit.");
+ }
+
+ if (!session.TryGetNewestCandidate(
+ transactionRevision,
+ out newestCandidateRevision,
+ out candidate))
+ {
+ throw new InvalidOperationException(
+ "The active transaction has no provisional revision to commit.");
+ }
+
+ provisionalRevision = newestCandidateRevision.ProvisionalEditRevision;
+
+ frozenCommitTransaction = transactionRevision;
+ frozenCommitRevision = provisionalRevision;
+ if (candidate is null)
+ {
+ if (!outstanding.TryGetValue(provisionalRevision, out work))
+ {
+ frozenCommitTransaction = null;
+ frozenCommitRevision = null;
+ return new AuthoringScheduledCommitResult(
+ newestCandidateRevision,
+ AuthoringEvaluationOutcomeStatus.Stale,
+ commitResult: null);
+ }
+
+ work.FinalCommitPriority = true;
+ workSignal.Release();
+ }
+ }
+
+ try
+ {
+ EvaluatedCandidateRevision candidateRevision;
+ AuthoringEvaluationOutcomeStatus evaluationStatus;
+ if (candidate != null)
+ {
+ candidateRevision = candidate.Revision;
+ evaluationStatus = candidate.IsCommitEligible
+ ? AuthoringEvaluationOutcomeStatus.Accepted
+ : AuthoringEvaluationOutcomeStatus.Rejected;
+ }
+ else
+ {
+ AuthoringEvaluationOutcome outcome;
+ try
+ {
+ outcome = await WaitWithCancellationAsync(
+ work!.Completion.Task,
+ cancellationToken).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ work!.Cancellation.Cancel();
+ return new AuthoringScheduledCommitResult(
+ work.Request.CandidateRevision,
+ AuthoringEvaluationOutcomeStatus.Cancelled,
+ commitResult: null);
+ }
+
+ candidateRevision = outcome.Request.CandidateRevision;
+ evaluationStatus = outcome.Status;
+ if (outcome.Status != AuthoringEvaluationOutcomeStatus.Accepted &&
+ outcome.Status != AuthoringEvaluationOutcomeStatus.Rejected)
+ {
+ return new AuthoringScheduledCommitResult(
+ candidateRevision,
+ outcome.Status,
+ commitResult: null);
+ }
+ }
+
+ AuthoringCommitResult commitResult = session.Commit(candidateRevision);
+ return new AuthoringScheduledCommitResult(
+ candidateRevision,
+ evaluationStatus,
+ commitResult);
+ }
+ finally
+ {
+ lock (gate)
+ {
+ if (frozenCommitTransaction == transactionRevision &&
+ frozenCommitRevision == provisionalRevision)
+ {
+ frozenCommitTransaction = null;
+ frozenCommitRevision = null;
+ }
+ }
+ }
+ }
+
+ public bool CancelTransaction(TransactionRevision transactionRevision)
+ {
+ WorkItem? pendingToComplete = null;
+ lock (gate)
+ {
+ ThrowIfDisposed();
+ if (pending != null &&
+ pending.Request.TransactionRevision == transactionRevision)
+ {
+ pendingToComplete = pending;
+ pending = null;
+ outstanding.Remove(
+ pendingToComplete.Request.ProvisionalEditRevision);
+ pendingToComplete.Cancellation.Cancel();
+ CompleteWithoutStartCore(
+ pendingToComplete,
+ AuthoringEvaluationOutcomeStatus.Cancelled,
+ countAsCoalesced: false);
+ }
+
+ if (running != null &&
+ running.Request.TransactionRevision == transactionRevision)
+ {
+ running.Cancellation.Cancel();
+ }
+ }
+
+ return session.Cancel(transactionRevision);
+ }
+
+ public EvaluationSchedulerSnapshot CaptureSnapshot()
+ {
+ lock (gate)
+ {
+ return new EvaluationSchedulerSnapshot(
+ submitted,
+ started,
+ completed,
+ accepted,
+ rejected,
+ stale,
+ coalesced,
+ cancelledBeforeStart,
+ cancelled,
+ faulted,
+ maximumPendingDepth,
+ pending is null ? 0 : 1,
+ running != null,
+ queueWaitTime,
+ candidateApplicationTime,
+ validationAndCompilationTime,
+ packagePreparationTime,
+ totalEvaluationTime,
+ submitToResultTime,
+ submitToPresentTime,
+ compilerInvocationCount);
+ }
+ }
+
+ public void Dispose()
+ {
+ DisposeAsync().AsTask().GetAwaiter().GetResult();
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ WorkItem? pendingToComplete;
+ Task? worker;
+ lock (gate)
+ {
+ if (disposed)
+ {
+ return;
+ }
+
+ disposed = true;
+ shutdown.Cancel();
+ pendingToComplete = pending;
+ pending = null;
+ if (pendingToComplete != null)
+ {
+ outstanding.Remove(
+ pendingToComplete.Request.ProvisionalEditRevision);
+ pendingToComplete.Cancellation.Cancel();
+ CompleteWithoutStartCore(
+ pendingToComplete,
+ AuthoringEvaluationOutcomeStatus.Cancelled,
+ countAsCoalesced: false);
+ }
+
+ running?.Cancellation.Cancel();
+ worker = workerTask;
+ workSignal.Release();
+ }
+
+ if (worker != null)
+ {
+ try
+ {
+ await worker.ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ }
+ }
+
+ workSignal.Dispose();
+ shutdown.Dispose();
+ }
+
+ private async Task WorkerLoopAsync()
+ {
+ while (!shutdown.IsCancellationRequested)
+ {
+ try
+ {
+ await workSignal.WaitAsync(shutdown.Token).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ break;
+ }
+
+ while (!shutdown.IsCancellationRequested)
+ {
+ WorkItem? item;
+ TimeSpan delay;
+ lock (gate)
+ {
+ item = pending;
+ if (item is null)
+ {
+ break;
+ }
+
+ if (item.Cancellation.IsCancellationRequested)
+ {
+ pending = null;
+ outstanding.Remove(item.Request.ProvisionalEditRevision);
+ CompleteWithoutStartCore(
+ item,
+ AuthoringEvaluationOutcomeStatus.Cancelled,
+ countAsCoalesced: false);
+ continue;
+ }
+
+ if (!session.IsEvaluationRequestCurrent(item.Request))
+ {
+ pending = null;
+ outstanding.Remove(item.Request.ProvisionalEditRevision);
+ CompleteWithoutStartCore(
+ item,
+ AuthoringEvaluationOutcomeStatus.Stale,
+ countAsCoalesced: false);
+ continue;
+ }
+
+ delay = item.FinalCommitPriority
+ ? TimeSpan.Zero
+ : RemainingThrottleDelayCore(clock.UtcNow);
+ if (delay == TimeSpan.Zero)
+ {
+ pending = null;
+ running = item;
+ item.StartedAtUtc = clock.UtcNow;
+ lastEvaluationStartedAtUtc = item.StartedAtUtc;
+ started++;
+ }
+ }
+
+ if (delay > TimeSpan.Zero)
+ {
+ Task delayTask = clock.Delay(delay, shutdown.Token);
+ Task signalTask = workSignal.WaitAsync(shutdown.Token);
+ try
+ {
+ await Task.WhenAny(delayTask, signalTask).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ return;
+ }
+
+ continue;
+ }
+
+ await EvaluateAndPublishAsync(item!).ConfigureAwait(false);
+ lock (gate)
+ {
+ if (ReferenceEquals(running, item))
+ {
+ running = null;
+ }
+
+ outstanding.Remove(item!.Request.ProvisionalEditRevision);
+ if (pending != null)
+ {
+ workSignal.Release();
+ }
+ }
+ }
+ }
+ }
+
+ private async Task EvaluateAndPublishAsync(WorkItem item)
+ {
+ TrackCandidateEvaluationProduct? product = null;
+ AuthoringCandidatePublicationResult? publication = null;
+ AuthoringEvaluationFault? evaluationFault = null;
+ AuthoringEvaluationOutcomeStatus status;
+ try
+ {
+ item.Cancellation.Token.ThrowIfCancellationRequested();
+ product = await evaluator.EvaluateAsync(
+ item.Request,
+ item.Cancellation.Token).ConfigureAwait(false);
+ item.Cancellation.Token.ThrowIfCancellationRequested();
+
+ if (!session.IsEvaluationRequestCurrent(item.Request))
+ {
+ status = AuthoringEvaluationOutcomeStatus.Stale;
+ }
+ else
+ {
+ try
+ {
+ publication = session.PublishCandidate(product);
+ }
+ catch (Exception exception)
+ {
+ evaluationFault = new AuthoringEvaluationFault(
+ AuthoringEvaluationPhase.Publication,
+ exception);
+ }
+
+ if (evaluationFault != null)
+ {
+ status = AuthoringEvaluationOutcomeStatus.Faulted;
+ }
+ else if (!publication!.AcceptedBySession)
+ {
+ status = AuthoringEvaluationOutcomeStatus.Stale;
+ }
+ else
+ {
+ status = product.Candidate.IsCommitEligible
+ ? AuthoringEvaluationOutcomeStatus.Accepted
+ : AuthoringEvaluationOutcomeStatus.Rejected;
+ }
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ status = AuthoringEvaluationOutcomeStatus.Cancelled;
+ }
+ catch (AuthoringEvaluationException exception)
+ {
+ evaluationFault = new AuthoringEvaluationFault(
+ exception.Phase,
+ exception.InnerException ?? exception);
+ status = AuthoringEvaluationOutcomeStatus.Faulted;
+ }
+ catch (Exception exception)
+ {
+ evaluationFault = new AuthoringEvaluationFault(
+ AuthoringEvaluationPhase.CandidateEvaluation,
+ exception);
+ status = AuthoringEvaluationOutcomeStatus.Faulted;
+ }
+
+ DateTimeOffset resultAtUtc = clock.UtcNow;
+ TimeSpan queueWait = item.StartedAtUtc.HasValue
+ ? NonNegative(item.StartedAtUtc.Value - item.Request.SubmittedAtUtc)
+ : TimeSpan.Zero;
+ TimeSpan submitToResult =
+ NonNegative(resultAtUtc - item.Request.SubmittedAtUtc);
+ TimeSpan? submitToPresent =
+ status == AuthoringEvaluationOutcomeStatus.Accepted
+ ? submitToResult
+ : (TimeSpan?)null;
+ var timing = new AuthoringEvaluationTiming(
+ queueWait,
+ product?.CandidateApplicationTime ?? TimeSpan.Zero,
+ product?.ValidationAndCompilationTime ?? TimeSpan.Zero,
+ product?.PackagePreparationTime ?? TimeSpan.Zero,
+ product?.TotalEvaluationTime ?? TimeSpan.Zero,
+ submitToResult,
+ submitToPresent,
+ product?.CompilerInvocationCount ?? 0);
+ IReadOnlyList diagnostics =
+ publication?.UpdateResult.Diagnostics ??
+ product?.Candidate.ApplicationDiagnostics ??
+ Array.Empty();
+ var outcome = new AuthoringEvaluationOutcome(
+ item.Request,
+ status,
+ product?.Candidate,
+ diagnostics,
+ timing,
+ evaluationFault);
+
+ lock (gate)
+ {
+ completed++;
+ RecordOutcomeCore(outcome);
+ }
+
+ item.Completion.TrySetResult(outcome);
+ item.Cancellation.Dispose();
+ }
+
+ private void CompleteWithoutStartCore(
+ WorkItem item,
+ AuthoringEvaluationOutcomeStatus status,
+ bool countAsCoalesced)
+ {
+ DateTimeOffset resultAtUtc = clock.UtcNow;
+ TimeSpan submitToResult =
+ NonNegative(resultAtUtc - item.Request.SubmittedAtUtc);
+ var timing = new AuthoringEvaluationTiming(
+ TimeSpan.Zero,
+ TimeSpan.Zero,
+ TimeSpan.Zero,
+ TimeSpan.Zero,
+ TimeSpan.Zero,
+ submitToResult,
+ submitToPresentTime: null,
+ compilerInvocationCount: 0);
+ var outcome = new AuthoringEvaluationOutcome(
+ item.Request,
+ status,
+ candidate: null,
+ Array.Empty(),
+ timing,
+ fault: null);
+ if (status == AuthoringEvaluationOutcomeStatus.Cancelled ||
+ status == AuthoringEvaluationOutcomeStatus.Coalesced)
+ {
+ cancelledBeforeStart++;
+ }
+ if (countAsCoalesced)
+ {
+ coalesced++;
+ }
+
+ RecordOutcomeCore(outcome);
+ item.Completion.TrySetResult(outcome);
+ item.Cancellation.Dispose();
+ }
+
+ private void RecordOutcomeCore(AuthoringEvaluationOutcome outcome)
+ {
+ switch (outcome.Status)
+ {
+ case AuthoringEvaluationOutcomeStatus.Accepted:
+ accepted++;
+ break;
+ case AuthoringEvaluationOutcomeStatus.Rejected:
+ rejected++;
+ break;
+ case AuthoringEvaluationOutcomeStatus.Stale:
+ stale++;
+ break;
+ case AuthoringEvaluationOutcomeStatus.Cancelled:
+ cancelled++;
+ break;
+ case AuthoringEvaluationOutcomeStatus.Faulted:
+ faulted++;
+ break;
+ }
+
+ queueWaitTime += outcome.Timing.QueueWaitTime;
+ candidateApplicationTime += outcome.Timing.CandidateApplicationTime;
+ validationAndCompilationTime +=
+ outcome.Timing.ValidationAndCompilationTime;
+ packagePreparationTime += outcome.Timing.PackagePreparationTime;
+ totalEvaluationTime += outcome.Timing.TotalEvaluationTime;
+ submitToResultTime += outcome.Timing.SubmitToResultTime;
+ submitToPresentTime += outcome.Timing.SubmitToPresentTime ?? TimeSpan.Zero;
+ compilerInvocationCount += outcome.Timing.CompilerInvocationCount;
+ }
+
+ private TimeSpan RemainingThrottleDelayCore(DateTimeOffset nowUtc)
+ {
+ if (!lastEvaluationStartedAtUtc.HasValue ||
+ options.MinimumEvaluationInterval == TimeSpan.Zero)
+ {
+ return TimeSpan.Zero;
+ }
+
+ TimeSpan elapsed = NonNegative(nowUtc - lastEvaluationStartedAtUtc.Value);
+ return elapsed >= options.MinimumEvaluationInterval
+ ? TimeSpan.Zero
+ : options.MinimumEvaluationInterval - elapsed;
+ }
+
+ private void ThrowIfDisposed()
+ {
+ if (disposed)
+ {
+ throw new ObjectDisposedException(
+ nameof(TrackAuthoringEvaluationCoordinator));
+ }
+ }
+
+ private static TimeSpan NonNegative(TimeSpan value) =>
+ value < TimeSpan.Zero ? TimeSpan.Zero : value;
+
+ private static async Task WaitWithCancellationAsync(
+ Task task,
+ CancellationToken cancellationToken)
+ {
+ if (!cancellationToken.CanBeCanceled)
+ {
+ return await task.ConfigureAwait(false);
+ }
+
+ var cancellationCompletion =
+ new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ using (cancellationToken.Register(
+ state => ((TaskCompletionSource)state!).TrySetResult(true),
+ cancellationCompletion))
+ {
+ Task completedTask = await Task.WhenAny(
+ task,
+ cancellationCompletion.Task).ConfigureAwait(false);
+ if (!ReferenceEquals(completedTask, task))
+ {
+ throw new OperationCanceledException(cancellationToken);
+ }
+ }
+
+ return await task.ConfigureAwait(false);
+ }
+
+ private sealed class WorkItem
+ {
+ internal WorkItem(
+ AuthoringEvaluationRequest request,
+ CancellationTokenSource cancellation)
+ {
+ Request = request;
+ Cancellation = cancellation;
+ Completion = new TaskCompletionSource(
+ TaskCreationOptions.RunContinuationsAsynchronously);
+ }
+
+ internal AuthoringEvaluationRequest Request { get; }
+
+ internal CancellationTokenSource Cancellation { get; }
+
+ internal TaskCompletionSource Completion { get; }
+
+ internal DateTimeOffset? StartedAtUtc { get; set; }
+
+ internal bool FinalCommitPriority { get; set; }
+ }
+ }
+}
diff --git a/Quantum.Application/Authoring/TrackAuthoringSession.cs b/Quantum.Application/Authoring/TrackAuthoringSession.cs
index f94f7fc..4fe477b 100644
--- a/Quantum.Application/Authoring/TrackAuthoringSession.cs
+++ b/Quantum.Application/Authoring/TrackAuthoringSession.cs
@@ -1,5 +1,4 @@
using System;
-using System.Collections.Generic;
using Quantum.IO.TrackLayout.V2;
using Quantum.Track.Authoring;
@@ -11,9 +10,11 @@ namespace Quantum.Application.Authoring
///
public sealed class TrackAuthoringSession
{
+ private readonly object syncRoot = new object();
private AuthoringSessionId sessionId;
private long committedRevisionSequence;
private long transactionRevisionSequence;
+ private long synchronousRequestRevisionSequence;
private CommittedTrackState committedState;
private PreparedTrackGraphState presentedState;
private InteractiveAuthoringTransaction? activeTransaction;
@@ -38,29 +39,47 @@ public TrackAuthoringSession(
SetCleanBaseline(initialState, markClean);
}
- public AuthoringSessionId SessionId => sessionId;
+ public AuthoringSessionId SessionId
+ {
+ get { lock (syncRoot) { return sessionId; } }
+ }
- public CommittedTrackState CommittedState => committedState;
+ public CommittedTrackState CommittedState
+ {
+ get { lock (syncRoot) { return committedState; } }
+ }
- public PreparedTrackGraphState PresentedState => presentedState;
+ public PreparedTrackGraphState PresentedState
+ {
+ get { lock (syncRoot) { return presentedState; } }
+ }
- public InteractiveAuthoringTransaction? ActiveTransaction => activeTransaction;
+ public InteractiveAuthoringTransaction? ActiveTransaction
+ {
+ get { lock (syncRoot) { return activeTransaction; } }
+ }
public AuthoringHistory History { get; }
- public bool HasCleanBaseline => hasCleanBaseline;
+ public bool HasCleanBaseline
+ {
+ get { lock (syncRoot) { return hasCleanBaseline; } }
+ }
- public string? CleanCanonicalPackageBaseline => cleanCanonicalPackageJson;
+ public string? CleanCanonicalPackageBaseline
+ {
+ get { lock (syncRoot) { return cleanCanonicalPackageJson; } }
+ }
- public string? PersistableCanonicalPackageJson =>
- committedState.CanonicalPackageJson;
+ public string? PersistableCanonicalPackageJson
+ {
+ get { lock (syncRoot) { return committedState.CanonicalPackageJson; } }
+ }
- public bool IsDirty =>
- !hasCleanBaseline ||
- !string.Equals(
- cleanCanonicalPackageJson,
- committedState.CanonicalPackageJson,
- StringComparison.Ordinal);
+ public bool IsDirty
+ {
+ get { lock (syncRoot) { return IsDirtyCore(); } }
+ }
public static TrackAuthoringSession Create(
TrackAuthoringGraph graph,
@@ -74,15 +93,18 @@ public static TrackAuthoringSession Create(
public AuthoringSessionSnapshot CaptureSnapshot()
{
- return new AuthoringSessionSnapshot(
- sessionId,
- committedState,
- presentedState,
- activeTransaction,
- IsDirty,
- History.UndoCount,
- History.RedoCount,
- History.RetainedPackageByteCount);
+ lock (syncRoot)
+ {
+ return new AuthoringSessionSnapshot(
+ sessionId,
+ committedState,
+ presentedState,
+ activeTransaction,
+ IsDirtyCore(),
+ History.UndoCount,
+ History.RedoCount,
+ History.RetainedPackageByteCount);
+ }
}
public InteractiveAuthoringTransaction BeginTransaction(
@@ -100,23 +122,27 @@ public InteractiveAuthoringTransaction BeginTransaction(
string parameterIdentity,
string operationDescription)
{
- if (activeTransaction != null)
+ lock (syncRoot)
{
- throw new InvalidOperationException(
- "Only one interactive authoring transaction may be active.");
- }
+ if (activeTransaction != null)
+ {
+ throw new InvalidOperationException(
+ "Only one interactive authoring transaction may be active.");
+ }
- transactionRevisionSequence++;
- activeTransaction = new InteractiveAuthoringTransaction(
- new TransactionRevision(sessionId, transactionRevisionSequence),
- committedState.Revision,
- committedState.PreparedState,
- targetSectionId,
- parameterIdentity,
- operationDescription,
- newestCandidate: null,
- presentedCandidate: null);
- return activeTransaction;
+ transactionRevisionSequence++;
+ activeTransaction = new InteractiveAuthoringTransaction(
+ new TransactionRevision(sessionId, transactionRevisionSequence),
+ committedState.Revision,
+ committedState.PreparedState,
+ targetSectionId,
+ parameterIdentity,
+ operationDescription,
+ newestProvisionalRevision: null,
+ newestCandidate: null,
+ presentedCandidate: null);
+ return activeTransaction;
+ }
}
///
@@ -132,181 +158,254 @@ public CandidateUpdateResult SubmitCandidate(
throw new ArgumentNullException(nameof(operation));
}
- if (activeTransaction is null)
+ AuthoringCandidateReservationResult reservation;
+ lock (syncRoot)
{
- return RejectedUpdate(
- AuthoringSessionDiagnosticCode.NoActiveTransaction,
- "There is no active authoring transaction.");
+ synchronousRequestRevisionSequence++;
+ reservation = ReserveCandidateCore(
+ transactionRevision,
+ operation,
+ new EvaluationRequestRevision(
+ sessionId,
+ synchronousRequestRevisionSequence),
+ DateTimeOffset.UtcNow);
}
- if (activeTransaction.Revision != transactionRevision)
+ if (reservation.Request is null)
{
- return RejectedUpdate(
- AuthoringSessionDiagnosticCode.TransactionRevisionMismatch,
- "The submitted transaction revision does not match the active transaction.");
+ return new CandidateUpdateResult(
+ wasEvaluated: false,
+ candidate: null,
+ reservation.Diagnostics);
}
- if (activeTransaction.BaseCommittedRevision != committedState.Revision)
+ TrackCandidateEvaluationProduct product =
+ new ProductionTrackCandidateEvaluator()
+ .EvaluateAsync(reservation.Request, System.Threading.CancellationToken.None)
+ .GetAwaiter()
+ .GetResult();
+ AuthoringCandidatePublicationResult publication = PublishCandidate(product);
+ return publication.UpdateResult;
+ }
+
+ internal AuthoringCandidateReservationResult ReserveCandidate(
+ TransactionRevision transactionRevision,
+ ITrackAuthoringCandidateOperation operation,
+ EvaluationRequestRevision requestRevision,
+ DateTimeOffset submittedAtUtc)
+ {
+ if (operation is null)
{
- return RejectedUpdate(
- AuthoringSessionDiagnosticCode.CommittedRevisionMismatch,
- "The transaction base no longer matches the committed source revision.");
+ throw new ArgumentNullException(nameof(operation));
}
- long provisionalSequence =
- (activeTransaction.NewestProvisionalRevision?.Sequence ?? 0) + 1;
- var provisionalRevision = new ProvisionalEditRevision(
- activeTransaction.Revision,
- provisionalSequence);
- var candidateRevision = new EvaluatedCandidateRevision(
- activeTransaction.BaseCommittedRevision,
- provisionalRevision);
+ lock (syncRoot)
+ {
+ return ReserveCandidateCore(
+ transactionRevision,
+ operation,
+ requestRevision,
+ submittedAtUtc);
+ }
+ }
- TrackAuthoringCandidateEvaluation evaluation =
- TrackAuthoringCandidateEvaluator.Evaluate(
- activeTransaction.BeforeState.Graph,
- operation);
- var diagnostics = new List();
- PreparedTrackGraphState? preparedState = null;
+ internal bool IsEvaluationRequestCurrent(AuthoringEvaluationRequest request)
+ {
+ if (request is null)
+ {
+ throw new ArgumentNullException(nameof(request));
+ }
+
+ lock (syncRoot)
+ {
+ return IsEvaluationRequestCurrentCore(request);
+ }
+ }
- if (!evaluation.CommitEligible || evaluation.CandidateGraph is null)
+ internal AuthoringCandidatePublicationResult PublishCandidate(
+ TrackCandidateEvaluationProduct product)
+ {
+ if (product is null)
{
- diagnostics.Add(new AuthoringSessionDiagnostic(
- AuthoringSessionDiagnosticCode.CandidateRejected,
- "The candidate operation did not produce a valid authoring state."));
+ throw new ArgumentNullException(nameof(product));
}
- else
+
+ lock (syncRoot)
{
- try
+ EvaluatedTrackCandidate candidate = product.Candidate;
+ if (!IsCandidateRevisionCurrentCore(candidate.Revision))
{
- preparedState = PreparedTrackGraphState.FromEvaluation(
- evaluation.CandidateGraph,
- activeTransaction.BeforeState.AncillaryState,
- evaluation.CompileResult);
+ return new AuthoringCandidatePublicationResult(
+ acceptedBySession: false,
+ new CandidateUpdateResult(
+ wasEvaluated: true,
+ candidate: null,
+ new[]
+ {
+ new AuthoringSessionDiagnostic(
+ AuthoringSessionDiagnosticCode.CandidateRevisionMismatch,
+ "The evaluated candidate is not the active newest provisional revision.")
+ }));
}
- catch (Exception exception) when (
- exception is ArgumentException ||
- exception is InvalidOperationException ||
- exception is NotSupportedException)
+
+ activeTransaction = activeTransaction!.WithCandidate(candidate);
+ if (candidate.IsCommitEligible)
{
- diagnostics.Add(new AuthoringSessionDiagnostic(
- AuthoringSessionDiagnosticCode.PersistencePreparationFailed,
- exception.Message));
+ presentedState = candidate.PreparedState!;
}
+
+ return new AuthoringCandidatePublicationResult(
+ acceptedBySession: true,
+ new CandidateUpdateResult(
+ wasEvaluated: true,
+ candidate,
+ candidate.ApplicationDiagnostics));
}
+ }
- var candidate = new EvaluatedTrackCandidate(
- candidateRevision,
- evaluation,
- preparedState,
- diagnostics);
- activeTransaction = activeTransaction.WithCandidate(candidate);
- if (candidate.IsCommitEligible)
+ internal bool TryGetNewestCandidate(
+ TransactionRevision transactionRevision,
+ out EvaluatedCandidateRevision candidateRevision,
+ out EvaluatedTrackCandidate? candidate)
+ {
+ lock (syncRoot)
{
- presentedState = candidate.PreparedState!;
- }
+ if (activeTransaction is null ||
+ activeTransaction.Revision != transactionRevision ||
+ !activeTransaction.NewestProvisionalRevision.HasValue)
+ {
+ candidateRevision = default;
+ candidate = null;
+ return false;
+ }
- return new CandidateUpdateResult(
- wasEvaluated: true,
- candidate,
- diagnostics);
+ ProvisionalEditRevision provisionalRevision =
+ activeTransaction.NewestProvisionalRevision.Value;
+ candidateRevision = new EvaluatedCandidateRevision(
+ activeTransaction.BaseCommittedRevision,
+ provisionalRevision);
+ candidate = activeTransaction.NewestCandidate?.Revision.ProvisionalEditRevision ==
+ provisionalRevision
+ ? activeTransaction.NewestCandidate
+ : null;
+ return true;
+ }
}
public AuthoringCommitResult Commit(EvaluatedCandidateRevision candidateRevision)
{
- if (activeTransaction is null)
+ lock (syncRoot)
{
- return RejectedCommit(
- AuthoringSessionDiagnosticCode.NoActiveTransaction,
- "There is no active authoring transaction.");
- }
+ if (activeTransaction is null)
+ {
+ return RejectedCommit(
+ AuthoringSessionDiagnosticCode.NoActiveTransaction,
+ "There is no active authoring transaction.");
+ }
- if (activeTransaction.BaseCommittedRevision != committedState.Revision)
- {
- return RejectedCommit(
- AuthoringSessionDiagnosticCode.CommittedRevisionMismatch,
- "The transaction base no longer matches the committed source revision.");
- }
+ if (activeTransaction.BaseCommittedRevision != committedState.Revision)
+ {
+ return RejectedCommit(
+ AuthoringSessionDiagnosticCode.CommittedRevisionMismatch,
+ "The transaction base no longer matches the committed source revision.");
+ }
- EvaluatedTrackCandidate? candidate = activeTransaction.NewestCandidate;
- if (candidate is null || candidate.Revision != candidateRevision)
- {
- return RejectedCommit(
- AuthoringSessionDiagnosticCode.CandidateRevisionMismatch,
- "Only the exact newest evaluated candidate revision may be committed.");
- }
+ EvaluatedTrackCandidate? candidate = activeTransaction.NewestCandidate;
+ if (!activeTransaction.NewestProvisionalRevision.HasValue ||
+ candidateRevision.ProvisionalEditRevision !=
+ activeTransaction.NewestProvisionalRevision.Value ||
+ candidate is null ||
+ candidate.Revision != candidateRevision)
+ {
+ return RejectedCommit(
+ AuthoringSessionDiagnosticCode.CandidateRevisionMismatch,
+ "Only the exact newest evaluated candidate revision may be committed.");
+ }
- if (!candidate.IsCommitEligible || candidate.PreparedState is null)
- {
- return RejectedCommit(
- AuthoringSessionDiagnosticCode.CandidateRejected,
- "The newest evaluated candidate is invalid and cannot be committed.");
- }
+ if (!candidate.IsCommitEligible || candidate.PreparedState is null)
+ {
+ return RejectedCommit(
+ AuthoringSessionDiagnosticCode.CandidateRejected,
+ "The newest evaluated candidate is invalid and cannot be committed.");
+ }
- PreparedTrackGraphState beforeState = activeTransaction.BeforeState;
- PreparedTrackGraphState afterState = candidate.PreparedState;
- if (beforeState.HasSameCanonicalContent(afterState))
- {
+ PreparedTrackGraphState beforeState = activeTransaction.BeforeState;
+ PreparedTrackGraphState afterState = candidate.PreparedState;
+ if (beforeState.HasSameCanonicalContent(afterState))
+ {
+ activeTransaction = null;
+ presentedState = committedState.PreparedState;
+ return new AuthoringCommitResult(
+ succeeded: true,
+ changed: false,
+ committedState,
+ Array.Empty());
+ }
+
+ History.Record(new AuthoringHistoryEntry(
+ activeTransaction.Description,
+ beforeState,
+ afterState));
+ AdoptCommittedState(afterState);
activeTransaction = null;
- presentedState = committedState.PreparedState;
return new AuthoringCommitResult(
succeeded: true,
- changed: false,
+ changed: true,
committedState,
Array.Empty());
}
-
- History.Record(new AuthoringHistoryEntry(
- activeTransaction.Description,
- beforeState,
- afterState));
- AdoptCommittedState(afterState);
- activeTransaction = null;
- return new AuthoringCommitResult(
- succeeded: true,
- changed: true,
- committedState,
- Array.Empty());
}
public bool Cancel(TransactionRevision transactionRevision)
{
- if (activeTransaction is null ||
- activeTransaction.Revision != transactionRevision)
+ lock (syncRoot)
{
- return false;
- }
+ if (activeTransaction is null ||
+ activeTransaction.Revision != transactionRevision)
+ {
+ return false;
+ }
- activeTransaction = null;
- presentedState = committedState.PreparedState;
- return true;
+ activeTransaction = null;
+ presentedState = committedState.PreparedState;
+ return true;
+ }
}
public bool Undo()
{
- if (activeTransaction != null || !History.TryUndo(out AuthoringHistoryEntry? entry))
+ lock (syncRoot)
{
- return false;
- }
+ if (activeTransaction != null || !History.TryUndo(out AuthoringHistoryEntry? entry))
+ {
+ return false;
+ }
- AdoptCommittedState(entry!.BeforeState);
- return true;
+ AdoptCommittedState(entry!.BeforeState);
+ return true;
+ }
}
public bool Redo()
{
- if (activeTransaction != null || !History.TryRedo(out AuthoringHistoryEntry? entry))
+ lock (syncRoot)
{
- return false;
- }
+ if (activeTransaction != null || !History.TryRedo(out AuthoringHistoryEntry? entry))
+ {
+ return false;
+ }
- AdoptCommittedState(entry!.AfterState);
- return true;
+ AdoptCommittedState(entry!.AfterState);
+ return true;
+ }
}
public void MarkClean()
{
- SetCleanBaseline(committedState.PreparedState, markClean: true);
+ lock (syncRoot)
+ {
+ SetCleanBaseline(committedState.PreparedState, markClean: true);
+ }
}
///
@@ -322,16 +421,20 @@ public void ReplaceSessionState(
throw new ArgumentNullException(nameof(replacement));
}
- sessionId = AuthoringSessionId.New();
- committedRevisionSequence = 0;
- transactionRevisionSequence = 0;
- committedState = new CommittedTrackState(
- replacement,
- new CommittedSourceRevision(sessionId, sequence: 0));
- presentedState = replacement;
- activeTransaction = null;
- History.Clear();
- SetCleanBaseline(replacement, markClean);
+ lock (syncRoot)
+ {
+ sessionId = AuthoringSessionId.New();
+ committedRevisionSequence = 0;
+ transactionRevisionSequence = 0;
+ synchronousRequestRevisionSequence = 0;
+ committedState = new CommittedTrackState(
+ replacement,
+ new CommittedSourceRevision(sessionId, sequence: 0));
+ presentedState = replacement;
+ activeTransaction = null;
+ History.Clear();
+ SetCleanBaseline(replacement, markClean);
+ }
}
public AuthoringOneShotResult ApplyOneShot(
@@ -355,6 +458,76 @@ public AuthoringOneShotResult ApplyOneShot(
return new AuthoringOneShotResult(update, commit);
}
+ private AuthoringCandidateReservationResult ReserveCandidateCore(
+ TransactionRevision transactionRevision,
+ ITrackAuthoringCandidateOperation operation,
+ EvaluationRequestRevision requestRevision,
+ DateTimeOffset submittedAtUtc)
+ {
+ if (activeTransaction is null)
+ {
+ return RejectedReservation(
+ AuthoringSessionDiagnosticCode.NoActiveTransaction,
+ "There is no active authoring transaction.");
+ }
+
+ if (requestRevision.SessionId != sessionId ||
+ activeTransaction.Revision != transactionRevision)
+ {
+ return RejectedReservation(
+ AuthoringSessionDiagnosticCode.TransactionRevisionMismatch,
+ "The submitted transaction revision does not match the active transaction.");
+ }
+
+ if (activeTransaction.BaseCommittedRevision != committedState.Revision)
+ {
+ return RejectedReservation(
+ AuthoringSessionDiagnosticCode.CommittedRevisionMismatch,
+ "The transaction base no longer matches the committed source revision.");
+ }
+
+ long provisionalSequence =
+ (activeTransaction.NewestProvisionalRevision?.Sequence ?? 0) + 1;
+ var provisionalRevision = new ProvisionalEditRevision(
+ activeTransaction.Revision,
+ provisionalSequence);
+ var candidateRevision = new EvaluatedCandidateRevision(
+ activeTransaction.BaseCommittedRevision,
+ provisionalRevision);
+ var request = new AuthoringEvaluationRequest(
+ requestRevision,
+ candidateRevision,
+ activeTransaction.BeforeState.Graph,
+ activeTransaction.BeforeState.AncillaryState,
+ operation,
+ submittedAtUtc);
+ activeTransaction =
+ activeTransaction.WithReservedProvisionalRevision(provisionalRevision);
+ return new AuthoringCandidateReservationResult(
+ request,
+ Array.Empty());
+ }
+
+ private bool IsEvaluationRequestCurrentCore(AuthoringEvaluationRequest request)
+ {
+ return request.SessionId == sessionId &&
+ ReferenceEquals(request.SourceGraph, activeTransaction?.BeforeState.Graph) &&
+ ReferenceEquals(request.AncillaryState, activeTransaction?.BeforeState.AncillaryState) &&
+ IsCandidateRevisionCurrentCore(request.CandidateRevision);
+ }
+
+ private bool IsCandidateRevisionCurrentCore(EvaluatedCandidateRevision revision)
+ {
+ return activeTransaction != null &&
+ revision.BaseCommittedRevision == committedState.Revision &&
+ revision.ProvisionalEditRevision.TransactionRevision ==
+ activeTransaction.Revision &&
+ activeTransaction.BaseCommittedRevision == committedState.Revision &&
+ activeTransaction.NewestProvisionalRevision.HasValue &&
+ revision.ProvisionalEditRevision ==
+ activeTransaction.NewestProvisionalRevision.Value;
+ }
+
private void AdoptCommittedState(PreparedTrackGraphState preparedState)
{
committedRevisionSequence++;
@@ -374,13 +547,21 @@ private void SetCleanBaseline(
: null;
}
- private static CandidateUpdateResult RejectedUpdate(
+ private bool IsDirtyCore()
+ {
+ return !hasCleanBaseline ||
+ !string.Equals(
+ cleanCanonicalPackageJson,
+ committedState.CanonicalPackageJson,
+ StringComparison.Ordinal);
+ }
+
+ private static AuthoringCandidateReservationResult RejectedReservation(
AuthoringSessionDiagnosticCode code,
string message)
{
- return new CandidateUpdateResult(
- wasEvaluated: false,
- candidate: null,
+ return new AuthoringCandidateReservationResult(
+ request: null,
new[] { new AuthoringSessionDiagnostic(code, message) });
}
diff --git a/Quantum.Application/Authoring/TrackCandidateEvaluationServices.cs b/Quantum.Application/Authoring/TrackCandidateEvaluationServices.cs
new file mode 100644
index 0000000..1d6df51
--- /dev/null
+++ b/Quantum.Application/Authoring/TrackCandidateEvaluationServices.cs
@@ -0,0 +1,198 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Threading;
+using System.Threading.Tasks;
+using Quantum.Track.Authoring;
+
+namespace Quantum.Application.Authoring
+{
+ internal sealed class AuthoringCandidateReservationResult
+ {
+ internal AuthoringCandidateReservationResult(
+ AuthoringEvaluationRequest? request,
+ IReadOnlyList diagnostics)
+ {
+ Request = request;
+ Diagnostics = diagnostics ?? throw new ArgumentNullException(nameof(diagnostics));
+ }
+
+ internal AuthoringEvaluationRequest? Request { get; }
+
+ internal IReadOnlyList Diagnostics { get; }
+ }
+
+ internal sealed class AuthoringCandidatePublicationResult
+ {
+ internal AuthoringCandidatePublicationResult(
+ bool acceptedBySession,
+ CandidateUpdateResult updateResult)
+ {
+ AcceptedBySession = acceptedBySession;
+ UpdateResult = updateResult ?? throw new ArgumentNullException(nameof(updateResult));
+ }
+
+ internal bool AcceptedBySession { get; }
+
+ internal CandidateUpdateResult UpdateResult { get; }
+ }
+
+ internal interface ITrackCandidateEvaluator
+ {
+ Task EvaluateAsync(
+ AuthoringEvaluationRequest request,
+ CancellationToken cancellationToken);
+ }
+
+ internal interface IAuthoringEvaluationClock
+ {
+ DateTimeOffset UtcNow { get; }
+
+ Task Delay(TimeSpan delay, CancellationToken cancellationToken);
+ }
+
+ internal sealed class SystemAuthoringEvaluationClock : IAuthoringEvaluationClock
+ {
+ public static SystemAuthoringEvaluationClock Instance { get; } =
+ new SystemAuthoringEvaluationClock();
+
+ private SystemAuthoringEvaluationClock()
+ {
+ }
+
+ public DateTimeOffset UtcNow => DateTimeOffset.UtcNow;
+
+ public Task Delay(TimeSpan delay, CancellationToken cancellationToken) =>
+ Task.Delay(delay, cancellationToken);
+ }
+
+ internal sealed class TrackCandidateEvaluationProduct
+ {
+ internal TrackCandidateEvaluationProduct(
+ EvaluatedTrackCandidate candidate,
+ TimeSpan candidateApplicationTime,
+ TimeSpan validationAndCompilationTime,
+ TimeSpan packagePreparationTime,
+ TimeSpan totalEvaluationTime,
+ int compilerInvocationCount)
+ {
+ Candidate = candidate ?? throw new ArgumentNullException(nameof(candidate));
+ CandidateApplicationTime = candidateApplicationTime;
+ ValidationAndCompilationTime = validationAndCompilationTime;
+ PackagePreparationTime = packagePreparationTime;
+ TotalEvaluationTime = totalEvaluationTime;
+ CompilerInvocationCount = compilerInvocationCount;
+ }
+
+ internal EvaluatedTrackCandidate Candidate { get; }
+
+ internal TimeSpan CandidateApplicationTime { get; }
+
+ internal TimeSpan ValidationAndCompilationTime { get; }
+
+ internal TimeSpan PackagePreparationTime { get; }
+
+ internal TimeSpan TotalEvaluationTime { get; }
+
+ internal int CompilerInvocationCount { get; }
+ }
+
+ internal sealed class AuthoringEvaluationException : Exception
+ {
+ internal AuthoringEvaluationException(
+ AuthoringEvaluationPhase phase,
+ Exception innerException)
+ : base(innerException.Message, innerException)
+ {
+ Phase = phase;
+ }
+
+ internal AuthoringEvaluationPhase Phase { get; }
+ }
+
+ internal sealed class ProductionTrackCandidateEvaluator : ITrackCandidateEvaluator
+ {
+ public Task EvaluateAsync(
+ AuthoringEvaluationRequest request,
+ CancellationToken cancellationToken)
+ {
+ if (request is null)
+ {
+ throw new ArgumentNullException(nameof(request));
+ }
+
+ cancellationToken.ThrowIfCancellationRequested();
+ var totalStopwatch = Stopwatch.StartNew();
+ TrackAuthoringCandidateEvaluation evaluation;
+ try
+ {
+ evaluation = TrackAuthoringCandidateEvaluator.Evaluate(
+ request.SourceGraph,
+ request.Operation);
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch (Exception exception)
+ {
+ throw new AuthoringEvaluationException(
+ AuthoringEvaluationPhase.CandidateEvaluation,
+ exception);
+ }
+
+ cancellationToken.ThrowIfCancellationRequested();
+ var packageStopwatch = Stopwatch.StartNew();
+ var diagnostics = new List();
+ PreparedTrackGraphState? preparedState = null;
+ if (!evaluation.CommitEligible || evaluation.CandidateGraph is null)
+ {
+ diagnostics.Add(new AuthoringSessionDiagnostic(
+ AuthoringSessionDiagnosticCode.CandidateRejected,
+ "The candidate operation did not produce a valid authoring state."));
+ }
+ else
+ {
+ try
+ {
+ preparedState = PreparedTrackGraphState.FromEvaluation(
+ evaluation.CandidateGraph,
+ request.AncillaryState,
+ evaluation.CompileResult);
+ }
+ catch (Exception exception) when (
+ exception is ArgumentException ||
+ exception is InvalidOperationException ||
+ exception is NotSupportedException)
+ {
+ diagnostics.Add(new AuthoringSessionDiagnostic(
+ AuthoringSessionDiagnosticCode.PersistencePreparationFailed,
+ exception.Message));
+ }
+ catch (Exception exception)
+ {
+ throw new AuthoringEvaluationException(
+ AuthoringEvaluationPhase.PackagePreparation,
+ exception);
+ }
+ }
+
+ packageStopwatch.Stop();
+ cancellationToken.ThrowIfCancellationRequested();
+ var candidate = new EvaluatedTrackCandidate(
+ request.CandidateRevision,
+ evaluation,
+ preparedState,
+ diagnostics);
+ totalStopwatch.Stop();
+ int compilerInvocationCount = evaluation.CompileResult is null ? 0 : 1;
+ return Task.FromResult(new TrackCandidateEvaluationProduct(
+ candidate,
+ evaluation.CandidateApplicationElapsed,
+ evaluation.ValidationAndCompilationElapsed,
+ packageStopwatch.Elapsed,
+ totalStopwatch.Elapsed,
+ compilerInvocationCount));
+ }
+ }
+}
diff --git a/Quantum.Application/Quantum.Application.csproj b/Quantum.Application/Quantum.Application.csproj
index 3caf31e..346b5b7 100644
--- a/Quantum.Application/Quantum.Application.csproj
+++ b/Quantum.Application/Quantum.Application.csproj
@@ -5,6 +5,12 @@
enable
+
+
+ <_Parameter1>Quantum.Tests
+
+
+
diff --git a/Quantum.Tests/Application/TrackAuthoringEvaluationCoordinatorIntegrationTests.cs b/Quantum.Tests/Application/TrackAuthoringEvaluationCoordinatorIntegrationTests.cs
new file mode 100644
index 0000000..e16003a
--- /dev/null
+++ b/Quantum.Tests/Application/TrackAuthoringEvaluationCoordinatorIntegrationTests.cs
@@ -0,0 +1,369 @@
+using Quantum.Application.Authoring;
+using Quantum.IO.TrackLayout.V2;
+using Quantum.Math;
+using Quantum.Splines;
+using Quantum.Track;
+using Quantum.Track.Authoring;
+
+namespace QuantumCoasterWorks.Tests.Application;
+
+public sealed class TrackAuthoringEvaluationCoordinatorIntegrationTests
+{
+ [Fact]
+ public async Task RealStraightCandidateMatchesSynchronousAndCommitDoesNotRecompile()
+ {
+ PreparedTrackGraphState initial = CreateStraightState(length: 10.0, banking: null);
+ var direct = new TrackAuthoringSession(initial);
+ var background = new TrackAuthoringSession(initial);
+ InteractiveAuthoringTransaction directTransaction = Begin(direct, "straight");
+ InteractiveAuthoringTransaction backgroundTransaction = Begin(background, "straight");
+ TrackAuthoringCandidateOperation operation = TrackAuthoringCandidateOperation.Replace(
+ "straight",
+ new StraightSectionDefinition("straight", 14.0));
+ CandidateUpdateResult directResult = direct.SubmitCandidate(
+ directTransaction.Revision,
+ operation);
+ await using var coordinator = new TrackAuthoringEvaluationCoordinator(
+ background,
+ BackgroundOptions());
+
+ AuthoringEvaluationOutcome backgroundResult = await coordinator
+ .SubmitProvisionalEdit(backgroundTransaction.Revision, operation)
+ .Completion;
+
+ Assert.Equal(AuthoringEvaluationOutcomeStatus.Accepted, backgroundResult.Status);
+ Assert.Equal(1, backgroundResult.Timing.CompilerInvocationCount);
+ Assert.Equal(
+ directResult.Candidate!.PreparedState!.CanonicalPackageJson,
+ backgroundResult.Candidate!.PreparedState!.CanonicalPackageJson);
+ using TrackAuthoringPipelineMeasurement measurement =
+ TrackAuthoringPipelineMeasurement.Begin();
+ AuthoringScheduledCommitResult commit =
+ await coordinator.CommitLatestAsync(backgroundTransaction.Revision);
+
+ Assert.True(commit.Succeeded);
+ Assert.Equal(0, measurement.GraphCompilerInvocationCount);
+ Assert.Same(
+ backgroundResult.Candidate.PreparedState,
+ background.CommittedState.PreparedState);
+ Assert.Equal(1, coordinator.CaptureSnapshot().CompilerInvocationCount);
+ Assert.Equal(1, background.History.UndoCount);
+ }
+
+ [Fact]
+ public async Task ExplicitBankingCompilationRejectionPreservesCommittedState()
+ {
+ var banking = new TrackBankingDefinition(new[]
+ {
+ new BankingProfileKey(0.0, 0.0),
+ new BankingProfileKey(10.0, 0.0)
+ });
+ PreparedTrackGraphState initial = CreateStraightState(10.0, banking);
+ var session = new TrackAuthoringSession(initial);
+ InteractiveAuthoringTransaction transaction = Begin(session, "straight");
+ await using var coordinator = new TrackAuthoringEvaluationCoordinator(
+ session,
+ BackgroundOptions());
+
+ AuthoringEvaluationOutcome outcome = await coordinator.SubmitProvisionalEdit(
+ transaction.Revision,
+ TrackAuthoringCandidateOperation.Append(
+ new StraightSectionDefinition("extra", 5.0))).Completion;
+ AuthoringScheduledCommitResult commit =
+ await coordinator.CommitLatestAsync(transaction.Revision);
+
+ Assert.Equal(AuthoringEvaluationOutcomeStatus.Rejected, outcome.Status);
+ Assert.Contains(
+ outcome.Candidate!.GraphDiagnostics,
+ diagnostic => diagnostic.Code ==
+ TrackAuthoringGraphDiagnosticCode.AuthoringCompilationFailed);
+ Assert.False(commit.Succeeded);
+ Assert.Same(initial, session.CommittedState.PreparedState);
+ Assert.Same(initial, session.PresentedState);
+ Assert.Equal(0, session.History.UndoCount);
+ Assert.False(session.IsDirty);
+ Assert.Equal(1, coordinator.CaptureSnapshot().CompilerInvocationCount);
+ }
+
+ [Fact]
+ public async Task SpatialGSharkCandidateHasSynchronousBackgroundParity()
+ {
+ SpatialSectionDefinition initialSection = CreateSpatial(
+ "spatial",
+ new[]
+ {
+ Vector3d.Zero,
+ new Vector3d(2.0, 0.0, 0.0),
+ new Vector3d(4.0, 1.4, 2.0),
+ new Vector3d(6.0, 2.0, 3.5)
+ });
+ SpatialSectionDefinition replacement = CreateSpatial(
+ "spatial",
+ new[]
+ {
+ Vector3d.Zero,
+ new Vector3d(1.5, 0.0, 0.0),
+ new Vector3d(3.5, -1.0, 1.5),
+ new Vector3d(6.5, -1.8, 2.5)
+ });
+ PreparedTrackGraphState initial = CreateSingleSectionState(initialSection);
+ var direct = new TrackAuthoringSession(initial);
+ var background = new TrackAuthoringSession(initial);
+ InteractiveAuthoringTransaction directTransaction = Begin(direct, "spatial");
+ InteractiveAuthoringTransaction backgroundTransaction = Begin(background, "spatial");
+ TrackAuthoringCandidateOperation operation =
+ TrackAuthoringCandidateOperation.Replace("spatial", replacement);
+ CandidateUpdateResult directResult = direct.SubmitCandidate(
+ directTransaction.Revision,
+ operation);
+ await using var coordinator = new TrackAuthoringEvaluationCoordinator(
+ background,
+ BackgroundOptions());
+
+ AuthoringEvaluationOutcome first = await coordinator.SubmitProvisionalEdit(
+ backgroundTransaction.Revision,
+ operation).Completion;
+
+ Assert.Equal(AuthoringEvaluationOutcomeStatus.Accepted, first.Status);
+ Assert.Equal(
+ directResult.Candidate!.PreparedState!.CanonicalPackageJson,
+ first.Candidate!.PreparedState!.CanonicalPackageJson);
+ Assert.Equal(1, first.Timing.CompilerInvocationCount);
+ Assert.Equal(
+ directResult.Candidate.Evaluation.Compilation!.Runtime.TotalLength,
+ first.Candidate.Evaluation.Compilation!.Runtime.TotalLength,
+ precision: 10);
+ }
+
+ [Fact]
+ public async Task RepeatedBackgroundEvaluationIsDeterministic()
+ {
+ var session = new TrackAuthoringSession(
+ CreateStraightState(length: 10.0, banking: null));
+ InteractiveAuthoringTransaction transaction = Begin(session, "straight");
+ TrackAuthoringCandidateOperation operation = TrackAuthoringCandidateOperation.Replace(
+ "straight",
+ new StraightSectionDefinition("straight", 13.0));
+ await using var coordinator = new TrackAuthoringEvaluationCoordinator(
+ session,
+ BackgroundOptions());
+
+ AuthoringEvaluationOutcome first = await coordinator.SubmitProvisionalEdit(
+ transaction.Revision,
+ operation).Completion;
+ AuthoringEvaluationOutcome second = await coordinator.SubmitProvisionalEdit(
+ transaction.Revision,
+ operation).Completion;
+
+ Assert.Equal(AuthoringEvaluationOutcomeStatus.Accepted, first.Status);
+ Assert.Equal(AuthoringEvaluationOutcomeStatus.Accepted, second.Status);
+ Assert.Equal(
+ first.Candidate!.PreparedState!.CanonicalPackageJson,
+ second.Candidate!.PreparedState!.CanonicalPackageJson);
+ Assert.Equal(2, coordinator.CaptureSnapshot().Started);
+ Assert.Equal(2, coordinator.CaptureSnapshot().CompilerInvocationCount);
+ }
+
+ [Fact]
+ public async Task ProductionBackgroundPipelineNeverOverlapsCandidateWork()
+ {
+ var session = new TrackAuthoringSession(
+ CreateStraightState(length: 10.0, banking: null));
+ InteractiveAuthoringTransaction transaction = Begin(session, "straight");
+ var concurrency = new OperationConcurrencyProbe();
+ using var firstRelease = new ManualResetEventSlim(false);
+ using var secondRelease = new ManualResetEventSlim(false);
+ var firstOperation = new BlockingStraightOperation(
+ 12.0,
+ concurrency,
+ firstRelease);
+ var secondOperation = new BlockingStraightOperation(
+ 16.0,
+ concurrency,
+ secondRelease);
+ await using var coordinator = new TrackAuthoringEvaluationCoordinator(
+ session,
+ BackgroundOptions());
+
+ AuthoringEvaluationSubmission first = coordinator.SubmitProvisionalEdit(
+ transaction.Revision,
+ firstOperation);
+ Assert.True(firstOperation.Entered.Wait(TimeSpan.FromSeconds(10)));
+ AuthoringEvaluationSubmission second = coordinator.SubmitProvisionalEdit(
+ transaction.Revision,
+ secondOperation);
+ Assert.False(secondOperation.Entered.Wait(TimeSpan.FromMilliseconds(100)));
+ firstRelease.Set();
+ Assert.True(secondOperation.Entered.Wait(TimeSpan.FromSeconds(10)));
+ secondRelease.Set();
+ AuthoringEvaluationOutcome[] outcomes =
+ await Task.WhenAll(first.Completion, second.Completion);
+
+ Assert.Equal(1, concurrency.MaximumActive);
+ Assert.Equal(2, coordinator.CaptureSnapshot().Started);
+ Assert.Equal(2, coordinator.CaptureSnapshot().CompilerInvocationCount);
+ Assert.Equal(AuthoringEvaluationOutcomeStatus.Stale, outcomes[0].Status);
+ Assert.Equal(AuthoringEvaluationOutcomeStatus.Accepted, outcomes[1].Status);
+ }
+
+ [Fact]
+ public void ApplicationSchedulingBoundaryHasNoFrontendOrRendererDependencies()
+ {
+ string[] forbiddenPrefixes =
+ {
+ "Avalonia",
+ "Unity",
+ "Unreal",
+ "Silk",
+ "OpenTK"
+ };
+ string[] references = typeof(TrackAuthoringEvaluationCoordinator).Assembly
+ .GetReferencedAssemblies()
+ .Select(reference => reference.Name ?? string.Empty)
+ .ToArray();
+
+ Assert.DoesNotContain(
+ references,
+ reference => forbiddenPrefixes.Any(prefix =>
+ reference.StartsWith(prefix, StringComparison.Ordinal)));
+ }
+
+ private static AuthoringEvaluationSchedulerOptions BackgroundOptions() =>
+ new AuthoringEvaluationSchedulerOptions(
+ AuthoringEvaluationExecutionMode.SerializedBackground,
+ TimeSpan.Zero);
+
+ private static InteractiveAuthoringTransaction Begin(
+ TrackAuthoringSession session,
+ string sectionId) =>
+ session.BeginTransaction(sectionId, "geometry", "Edit section geometry");
+
+ private static PreparedTrackGraphState CreateStraightState(
+ double length,
+ TrackBankingDefinition? banking)
+ {
+ var graph = new TrackAuthoringGraph(
+ new[]
+ {
+ new TrackAuthoringGraphNode(
+ new StraightSectionDefinition("straight", length))
+ },
+ Array.Empty(),
+ TrackStartPose.Identity,
+ banking);
+ return Prepare(graph);
+ }
+
+ private static PreparedTrackGraphState CreateSingleSectionState(
+ TrackAuthoringSectionDefinition definition)
+ {
+ return Prepare(new TrackAuthoringGraph(
+ new[] { new TrackAuthoringGraphNode(definition) },
+ Array.Empty()));
+ }
+
+ private static PreparedTrackGraphState Prepare(TrackAuthoringGraph graph) =>
+ PreparedTrackGraphState.Prepare(
+ graph,
+ new TrackLayoutPackageV2GraphAncillaryState(
+ TrackLayoutPackageV2Dto.ContractName,
+ TrackLayoutPackageV2Dto.ContractVersion,
+ "meters",
+ "M167.3 self-authored integration test",
+ "m167-3-integration-test",
+ heartlineOffset: null));
+
+ private static SpatialSectionDefinition CreateSpatial(
+ string id,
+ IReadOnlyList controlPoints)
+ {
+ const int degree = 3;
+ double[] weights = Enumerable.Repeat(1.0, controlPoints.Count).ToArray();
+ var curve = new GSharkNurbsCurveAdapter(
+ controlPoints.ToList(),
+ weights.ToList(),
+ degree);
+ TrackSamplingOptions options = TrackSamplingOptions.Default;
+ double length = new ArcLengthLUT(
+ curve,
+ options.ArcLengthSamples,
+ options.ArcLengthTolerance).TotalLength;
+ return new SpatialSectionDefinition(
+ id,
+ length,
+ controlPoints,
+ degree,
+ weights);
+ }
+
+ private sealed class OperationConcurrencyProbe
+ {
+ private int active;
+ private int maximumActive;
+
+ internal int MaximumActive => Volatile.Read(ref maximumActive);
+
+ internal void Enter()
+ {
+ int current = Interlocked.Increment(ref active);
+ int observed;
+ do
+ {
+ observed = Volatile.Read(ref maximumActive);
+ if (observed >= current)
+ {
+ break;
+ }
+ }
+ while (Interlocked.CompareExchange(
+ ref maximumActive,
+ current,
+ observed) != observed);
+ }
+
+ internal void Exit() => Interlocked.Decrement(ref active);
+ }
+
+ private sealed class BlockingStraightOperation : ITrackAuthoringCandidateOperation
+ {
+ private readonly double length;
+ private readonly OperationConcurrencyProbe concurrency;
+ private readonly ManualResetEventSlim release;
+
+ internal BlockingStraightOperation(
+ double length,
+ OperationConcurrencyProbe concurrency,
+ ManualResetEventSlim release)
+ {
+ this.length = length;
+ this.concurrency = concurrency;
+ this.release = release;
+ }
+
+ internal ManualResetEventSlim Entered { get; } = new ManualResetEventSlim(false);
+
+ public string OperationTypeId => "test.blockingStraight";
+
+ public TrackAuthoringGraph Apply(TrackAuthoringGraph sourceGraph)
+ {
+ concurrency.Enter();
+ Entered.Set();
+ try
+ {
+ if (!release.Wait(TimeSpan.FromSeconds(10)))
+ {
+ throw new TimeoutException("Timed out waiting to release candidate work.");
+ }
+
+ return TrackAuthoringGraphOperations.Replace(
+ sourceGraph,
+ "straight",
+ new StraightSectionDefinition("straight", length));
+ }
+ finally
+ {
+ concurrency.Exit();
+ }
+ }
+ }
+}
diff --git a/Quantum.Tests/Application/TrackAuthoringEvaluationCoordinatorTests.cs b/Quantum.Tests/Application/TrackAuthoringEvaluationCoordinatorTests.cs
new file mode 100644
index 0000000..21b736e
--- /dev/null
+++ b/Quantum.Tests/Application/TrackAuthoringEvaluationCoordinatorTests.cs
@@ -0,0 +1,707 @@
+using Quantum.Application.Authoring;
+using Quantum.IO.TrackLayout.V2;
+using Quantum.Track.Authoring;
+
+namespace QuantumCoasterWorks.Tests.Application;
+
+public sealed class TrackAuthoringEvaluationCoordinatorTests
+{
+ [Fact]
+ public async Task SynchronousModeMatchesDirectSessionEvaluationAndCommit()
+ {
+ TrackAuthoringSession direct = CreateSession();
+ TrackAuthoringSession scheduled = CreateSession();
+ InteractiveAuthoringTransaction directTransaction = Begin(direct);
+ InteractiveAuthoringTransaction scheduledTransaction = Begin(scheduled);
+ CandidateUpdateResult directUpdate = direct.SubmitCandidate(
+ directTransaction.Revision,
+ RadiusOperation(35.0));
+ using var coordinator = new TrackAuthoringEvaluationCoordinator(scheduled);
+
+ AuthoringEvaluationSubmission submission = coordinator.SubmitProvisionalEdit(
+ scheduledTransaction.Revision,
+ RadiusOperation(35.0));
+ AuthoringEvaluationOutcome outcome = await submission.Completion;
+ AuthoringCommitResult directCommit = direct.Commit(directUpdate.Candidate!.Revision);
+ AuthoringScheduledCommitResult scheduledCommit =
+ await coordinator.CommitLatestAsync(scheduledTransaction.Revision);
+
+ Assert.True(submission.Completion.IsCompleted);
+ Assert.Equal(AuthoringEvaluationOutcomeStatus.Accepted, outcome.Status);
+ Assert.True(directCommit.Succeeded);
+ Assert.True(scheduledCommit.Succeeded);
+ Assert.Equal(
+ direct.CommittedState.CanonicalPackageJson,
+ scheduled.CommittedState.CanonicalPackageJson);
+ Assert.Equal(35.0, Radius(scheduled.CommittedState.SourceGraph));
+ }
+
+ [Fact]
+ public async Task BackgroundModeKeepsOneRunningAndOnlyLatestPending()
+ {
+ TrackAuthoringSession session = CreateSession();
+ InteractiveAuthoringTransaction transaction = Begin(session);
+ var evaluator = new ControlledEvaluator();
+ await using var coordinator = CreateBackground(session, evaluator);
+ AuthoringEvaluationSubmission first = coordinator.SubmitProvisionalEdit(
+ transaction.Revision,
+ RadiusOperation(25.0));
+ await evaluator.WaitForInvocationAsync(0);
+
+ AuthoringEvaluationSubmission second = coordinator.SubmitProvisionalEdit(
+ transaction.Revision,
+ RadiusOperation(30.0));
+ AuthoringEvaluationSubmission third = coordinator.SubmitProvisionalEdit(
+ transaction.Revision,
+ RadiusOperation(35.0));
+ AuthoringEvaluationSubmission fourth = coordinator.SubmitProvisionalEdit(
+ transaction.Revision,
+ RadiusOperation(40.0));
+
+ Assert.Equal(
+ AuthoringEvaluationOutcomeStatus.Coalesced,
+ (await second.Completion).Status);
+ Assert.Equal(
+ AuthoringEvaluationOutcomeStatus.Coalesced,
+ (await third.Completion).Status);
+ evaluator.Release(0);
+ await evaluator.WaitForInvocationAsync(1);
+ Assert.Equal(fourth.Request.RequestRevision, evaluator.RequestAt(1).RequestRevision);
+ evaluator.Release(1);
+
+ Assert.Equal(AuthoringEvaluationOutcomeStatus.Stale, (await first.Completion).Status);
+ Assert.Equal(AuthoringEvaluationOutcomeStatus.Accepted, (await fourth.Completion).Status);
+ EvaluationSchedulerSnapshot snapshot = coordinator.CaptureSnapshot();
+ Assert.Equal(4, snapshot.Submitted);
+ Assert.Equal(2, snapshot.Started);
+ Assert.Equal(2, snapshot.Completed);
+ Assert.Equal(2, snapshot.Coalesced);
+ Assert.Equal(2, snapshot.CancelledBeforeStart);
+ Assert.Equal(1, snapshot.MaximumPendingDepth);
+ Assert.Equal(1, evaluator.MaximumConcurrentEvaluations);
+ Assert.Equal(40.0, Radius(session.PresentedState.Graph));
+ }
+
+ [Fact]
+ public async Task OlderCompletionAfterNewerAcceptedResultCannotBeAdopted()
+ {
+ TrackAuthoringSession session = CreateSession();
+ InteractiveAuthoringTransaction transaction = Begin(session);
+ var evaluator = new ControlledEvaluator();
+ await using var coordinator = CreateBackground(session, evaluator);
+ AuthoringEvaluationSubmission older = coordinator.SubmitProvisionalEdit(
+ transaction.Revision,
+ RadiusOperation(30.0));
+ await evaluator.WaitForInvocationAsync(0);
+
+ CandidateUpdateResult newer = session.SubmitCandidate(
+ transaction.Revision,
+ RadiusOperation(45.0));
+ PreparedTrackGraphState newerPresentation = session.PresentedState;
+ evaluator.Release(0);
+ AuthoringEvaluationOutcome outcome = await older.Completion;
+
+ Assert.True(newer.CandidateAccepted);
+ Assert.Equal(AuthoringEvaluationOutcomeStatus.Stale, outcome.Status);
+ Assert.Same(newerPresentation, session.PresentedState);
+ Assert.Equal(45.0, Radius(session.PresentedState.Graph));
+ }
+
+ [Fact]
+ public async Task StaleValidResultCannotReplaceNewerInvalidState()
+ {
+ TrackAuthoringSession session = CreateSession();
+ PreparedTrackGraphState committedPresentation = session.PresentedState;
+ InteractiveAuthoringTransaction transaction = Begin(session);
+ var evaluator = new ControlledEvaluator();
+ await using var coordinator = CreateBackground(session, evaluator);
+ AuthoringEvaluationSubmission olderValid = coordinator.SubmitProvisionalEdit(
+ transaction.Revision,
+ RadiusOperation(30.0));
+ await evaluator.WaitForInvocationAsync(0);
+
+ CandidateUpdateResult newerInvalid = session.SubmitCandidate(
+ transaction.Revision,
+ new DisconnectedRouteOperation());
+ evaluator.Release(0);
+ AuthoringEvaluationOutcome outcome = await olderValid.Completion;
+
+ Assert.False(newerInvalid.CandidateAccepted);
+ Assert.Equal(AuthoringEvaluationOutcomeStatus.Stale, outcome.Status);
+ Assert.Same(committedPresentation, session.PresentedState);
+ Assert.Same(newerInvalid.Candidate, session.ActiveTransaction!.NewestCandidate);
+ Assert.NotEmpty(session.ActiveTransaction.NewestCandidate!.GraphDiagnostics);
+ }
+
+ [Fact]
+ public async Task StaleInvalidResultCannotReplaceNewerValidDiagnostics()
+ {
+ TrackAuthoringSession session = CreateSession();
+ InteractiveAuthoringTransaction transaction = Begin(session);
+ var evaluator = new ControlledEvaluator();
+ await using var coordinator = CreateBackground(session, evaluator);
+ AuthoringEvaluationSubmission olderInvalid = coordinator.SubmitProvisionalEdit(
+ transaction.Revision,
+ new DisconnectedRouteOperation());
+ await evaluator.WaitForInvocationAsync(0);
+
+ CandidateUpdateResult newerValid = session.SubmitCandidate(
+ transaction.Revision,
+ RadiusOperation(42.0));
+ evaluator.Release(0);
+ AuthoringEvaluationOutcome outcome = await olderInvalid.Completion;
+
+ Assert.Equal(AuthoringEvaluationOutcomeStatus.Stale, outcome.Status);
+ Assert.Same(newerValid.Candidate, session.ActiveTransaction!.NewestCandidate);
+ Assert.Empty(session.ActiveTransaction.NewestCandidate!.GraphDiagnostics);
+ Assert.Equal(42.0, Radius(session.PresentedState.Graph));
+ }
+
+ [Fact]
+ public async Task TransactionCancellationPreventsPendingStartAndRunningAdoption()
+ {
+ TrackAuthoringSession session = CreateSession();
+ InteractiveAuthoringTransaction transaction = Begin(session);
+ var evaluator = new ControlledEvaluator();
+ await using var coordinator = CreateBackground(session, evaluator);
+ AuthoringEvaluationSubmission running = coordinator.SubmitProvisionalEdit(
+ transaction.Revision,
+ RadiusOperation(30.0));
+ await evaluator.WaitForInvocationAsync(0);
+ AuthoringEvaluationSubmission pending = coordinator.SubmitProvisionalEdit(
+ transaction.Revision,
+ RadiusOperation(40.0));
+
+ Assert.True(coordinator.CancelTransaction(transaction.Revision));
+ Assert.Equal(
+ AuthoringEvaluationOutcomeStatus.Cancelled,
+ (await pending.Completion).Status);
+ evaluator.Release(0);
+ Assert.Equal(
+ AuthoringEvaluationOutcomeStatus.Cancelled,
+ (await running.Completion).Status);
+
+ Assert.Null(session.ActiveTransaction);
+ Assert.Same(session.CommittedState.PreparedState, session.PresentedState);
+ Assert.Equal(0, session.History.UndoCount);
+ Assert.Equal(1, evaluator.InvocationCount);
+ Assert.Equal(1, coordinator.CaptureSnapshot().CancelledBeforeStart);
+ }
+
+ [Fact]
+ public async Task CancellationDuringNonCancellableEvaluationPreventsAdoption()
+ {
+ TrackAuthoringSession session = CreateSession();
+ InteractiveAuthoringTransaction transaction = Begin(session);
+ var evaluator = new ControlledEvaluator();
+ await using var coordinator = CreateBackground(session, evaluator);
+ using var cancellation = new CancellationTokenSource();
+ AuthoringEvaluationSubmission submission = coordinator.SubmitProvisionalEdit(
+ transaction.Revision,
+ RadiusOperation(35.0),
+ cancellation.Token);
+ await evaluator.WaitForInvocationAsync(0);
+
+ cancellation.Cancel();
+ evaluator.Release(0);
+ AuthoringEvaluationOutcome outcome = await submission.Completion;
+
+ Assert.Equal(AuthoringEvaluationOutcomeStatus.Cancelled, outcome.Status);
+ Assert.Equal(20.0, Radius(session.PresentedState.Graph));
+ Assert.Null(session.ActiveTransaction!.NewestCandidate);
+ Assert.Equal(1, coordinator.CaptureSnapshot().Cancelled);
+ }
+
+ [Fact]
+ public async Task SessionReplacementInvalidatesRunningAndPendingRequests()
+ {
+ TrackAuthoringSession session = CreateSession();
+ InteractiveAuthoringTransaction transaction = Begin(session);
+ var evaluator = new ControlledEvaluator();
+ await using var coordinator = CreateBackground(session, evaluator);
+ AuthoringEvaluationSubmission running = coordinator.SubmitProvisionalEdit(
+ transaction.Revision,
+ RadiusOperation(30.0));
+ await evaluator.WaitForInvocationAsync(0);
+ AuthoringEvaluationSubmission pending = coordinator.SubmitProvisionalEdit(
+ transaction.Revision,
+ RadiusOperation(40.0));
+ PreparedTrackGraphState replacement = CreatePreparedState(60.0);
+
+ session.ReplaceSessionState(replacement, markClean: true);
+ evaluator.Release(0);
+
+ Assert.Equal(AuthoringEvaluationOutcomeStatus.Stale, (await running.Completion).Status);
+ Assert.Equal(AuthoringEvaluationOutcomeStatus.Stale, (await pending.Completion).Status);
+ Assert.Equal(1, evaluator.InvocationCount);
+ Assert.Same(replacement, session.CommittedState.PreparedState);
+ Assert.Same(replacement, session.PresentedState);
+ Assert.Equal(0, session.History.UndoCount);
+ }
+
+ [Fact]
+ public async Task EvaluatorFaultLeavesCommittedSessionUsable()
+ {
+ TrackAuthoringSession session = CreateSession();
+ InteractiveAuthoringTransaction transaction = Begin(session);
+ var evaluator = new ControlledEvaluator(faultedInvocationIndexes: new[] { 0 });
+ await using var coordinator = CreateBackground(session, evaluator);
+ AuthoringEvaluationSubmission faulted = coordinator.SubmitProvisionalEdit(
+ transaction.Revision,
+ RadiusOperation(30.0));
+ await evaluator.WaitForInvocationAsync(0);
+ evaluator.Release(0);
+
+ AuthoringEvaluationOutcome faultOutcome = await faulted.Completion;
+ Assert.Equal(AuthoringEvaluationOutcomeStatus.Faulted, faultOutcome.Status);
+ Assert.NotNull(faultOutcome.Fault);
+ Assert.Equal(AuthoringEvaluationPhase.CandidateEvaluation, faultOutcome.Fault!.Phase);
+ Assert.Equal(20.0, Radius(session.CommittedState.SourceGraph));
+ Assert.NotNull(session.ActiveTransaction);
+
+ AuthoringEvaluationSubmission recovery = coordinator.SubmitProvisionalEdit(
+ transaction.Revision,
+ RadiusOperation(32.0));
+ await evaluator.WaitForInvocationAsync(1);
+ evaluator.Release(1);
+ Assert.Equal(
+ AuthoringEvaluationOutcomeStatus.Accepted,
+ (await recovery.Completion).Status);
+ Assert.True((await coordinator.CommitLatestAsync(transaction.Revision)).Succeeded);
+ Assert.Equal(32.0, Radius(session.CommittedState.SourceGraph));
+ }
+
+ [Fact]
+ public async Task CommitWaitsForExactNewestRevision()
+ {
+ TrackAuthoringSession session = CreateSession();
+ InteractiveAuthoringTransaction transaction = Begin(session);
+ var evaluator = new ControlledEvaluator();
+ await using var coordinator = CreateBackground(session, evaluator);
+ AuthoringEvaluationSubmission older = coordinator.SubmitProvisionalEdit(
+ transaction.Revision,
+ RadiusOperation(30.0));
+ await evaluator.WaitForInvocationAsync(0);
+ AuthoringEvaluationSubmission newest = coordinator.SubmitProvisionalEdit(
+ transaction.Revision,
+ RadiusOperation(40.0));
+
+ Task commitTask =
+ coordinator.CommitLatestAsync(transaction.Revision);
+ Assert.False(commitTask.IsCompleted);
+ evaluator.Release(0);
+ await evaluator.WaitForInvocationAsync(1);
+ Assert.Equal(newest.Request.CandidateRevision, evaluator.RequestAt(1).CandidateRevision);
+ evaluator.Release(1);
+ AuthoringScheduledCommitResult commit = await commitTask;
+
+ Assert.Equal(AuthoringEvaluationOutcomeStatus.Stale, (await older.Completion).Status);
+ Assert.Equal(AuthoringEvaluationOutcomeStatus.Accepted, (await newest.Completion).Status);
+ Assert.True(commit.Succeeded);
+ Assert.Equal(newest.Request.CandidateRevision, commit.CandidateRevision);
+ Assert.Equal(40.0, Radius(session.CommittedState.SourceGraph));
+ Assert.Equal(1, session.History.UndoCount);
+ }
+
+ [Fact]
+ public async Task TransactionCancellationWhileCommitWaitsCannotCommit()
+ {
+ TrackAuthoringSession session = CreateSession();
+ InteractiveAuthoringTransaction transaction = Begin(session);
+ var evaluator = new ControlledEvaluator();
+ await using var coordinator = CreateBackground(session, evaluator);
+ AuthoringEvaluationSubmission submission = coordinator.SubmitProvisionalEdit(
+ transaction.Revision,
+ RadiusOperation(36.0));
+ await evaluator.WaitForInvocationAsync(0);
+ Task commitTask =
+ coordinator.CommitLatestAsync(transaction.Revision);
+
+ Assert.True(coordinator.CancelTransaction(transaction.Revision));
+ evaluator.Release(0);
+ AuthoringScheduledCommitResult commit = await commitTask;
+
+ Assert.Equal(AuthoringEvaluationOutcomeStatus.Cancelled, commit.EvaluationStatus);
+ Assert.Null(commit.CommitResult);
+ Assert.Equal(
+ AuthoringEvaluationOutcomeStatus.Cancelled,
+ (await submission.Completion).Status);
+ Assert.Equal(20.0, Radius(session.CommittedState.SourceGraph));
+ Assert.Equal(0, session.History.UndoCount);
+ Assert.Null(session.ActiveTransaction);
+ }
+
+ [Fact]
+ public async Task CommitNeverAdoptsOlderLastGoodPreviewWhenNewestIsInvalid()
+ {
+ TrackAuthoringSession session = CreateSession();
+ InteractiveAuthoringTransaction transaction = Begin(session);
+ var evaluator = new ControlledEvaluator();
+ await using var coordinator = CreateBackground(session, evaluator);
+ AuthoringEvaluationSubmission valid = coordinator.SubmitProvisionalEdit(
+ transaction.Revision,
+ RadiusOperation(30.0));
+ await evaluator.WaitForInvocationAsync(0);
+ evaluator.Release(0);
+ Assert.Equal(AuthoringEvaluationOutcomeStatus.Accepted, (await valid.Completion).Status);
+ Assert.Equal(30.0, Radius(session.PresentedState.Graph));
+
+ AuthoringEvaluationSubmission invalid = coordinator.SubmitProvisionalEdit(
+ transaction.Revision,
+ new DisconnectedRouteOperation());
+ await evaluator.WaitForInvocationAsync(1);
+ Task commitTask =
+ coordinator.CommitLatestAsync(transaction.Revision);
+ evaluator.Release(1);
+ AuthoringScheduledCommitResult commit = await commitTask;
+
+ Assert.Equal(AuthoringEvaluationOutcomeStatus.Rejected, (await invalid.Completion).Status);
+ Assert.False(commit.Succeeded);
+ Assert.Contains(
+ commit.CommitResult!.Diagnostics,
+ diagnostic => diagnostic.Code == AuthoringSessionDiagnosticCode.CandidateRejected);
+ Assert.Equal(20.0, Radius(session.CommittedState.SourceGraph));
+ Assert.Equal(30.0, Radius(session.PresentedState.Graph));
+ Assert.Equal(0, session.History.UndoCount);
+ }
+
+ [Fact]
+ public async Task FinalCommitRevisionBypassesNormalThrottle()
+ {
+ TrackAuthoringSession session = CreateSession();
+ InteractiveAuthoringTransaction transaction = Begin(session);
+ var evaluator = new ControlledEvaluator();
+ var clock = new FakeEvaluationClock();
+ var options = new AuthoringEvaluationSchedulerOptions(
+ AuthoringEvaluationExecutionMode.SerializedBackground,
+ TimeSpan.FromHours(1));
+ await using var coordinator = new TrackAuthoringEvaluationCoordinator(
+ session,
+ options,
+ evaluator,
+ clock);
+ AuthoringEvaluationSubmission first = coordinator.SubmitProvisionalEdit(
+ transaction.Revision,
+ RadiusOperation(30.0));
+ await evaluator.WaitForInvocationAsync(0);
+ evaluator.Release(0);
+ await first.Completion;
+ AuthoringEvaluationSubmission final = coordinator.SubmitProvisionalEdit(
+ transaction.Revision,
+ RadiusOperation(38.0));
+ await clock.WaitForDelayAsync();
+
+ Task commitTask =
+ coordinator.CommitLatestAsync(transaction.Revision);
+ await evaluator.WaitForInvocationAsync(1);
+ evaluator.Release(1);
+ AuthoringScheduledCommitResult commit = await commitTask;
+
+ Assert.True(commit.Succeeded);
+ Assert.Equal(AuthoringEvaluationOutcomeStatus.Accepted, (await final.Completion).Status);
+ Assert.Equal(38.0, Radius(session.CommittedState.SourceGraph));
+ Assert.Equal(DateTimeOffset.UnixEpoch, clock.UtcNow);
+ }
+
+ [Fact]
+ public async Task CountersAndTimingsFollowFakeClockAndEvaluator()
+ {
+ TrackAuthoringSession session = CreateSession();
+ InteractiveAuthoringTransaction transaction = Begin(session);
+ var evaluator = new ControlledEvaluator();
+ var clock = new FakeEvaluationClock();
+ await using var coordinator = CreateBackground(session, evaluator, clock);
+ AuthoringEvaluationSubmission first = coordinator.SubmitProvisionalEdit(
+ transaction.Revision,
+ RadiusOperation(30.0));
+ await evaluator.WaitForInvocationAsync(0);
+ AuthoringEvaluationSubmission second = coordinator.SubmitProvisionalEdit(
+ transaction.Revision,
+ RadiusOperation(40.0));
+ clock.Advance(TimeSpan.FromMilliseconds(10));
+ evaluator.Release(0);
+ await evaluator.WaitForInvocationAsync(1);
+ clock.Advance(TimeSpan.FromMilliseconds(5));
+ evaluator.Release(1);
+ await Task.WhenAll(first.Completion, second.Completion);
+
+ EvaluationSchedulerSnapshot snapshot = coordinator.CaptureSnapshot();
+ Assert.Equal(2, snapshot.Submitted);
+ Assert.Equal(2, snapshot.Started);
+ Assert.Equal(2, snapshot.Completed);
+ Assert.Equal(1, snapshot.Accepted);
+ Assert.Equal(1, snapshot.Stale);
+ Assert.Equal(TimeSpan.FromMilliseconds(10), snapshot.QueueWaitTime);
+ Assert.Equal(TimeSpan.FromMilliseconds(2), snapshot.CandidateApplicationTime);
+ Assert.Equal(TimeSpan.FromMilliseconds(4), snapshot.ValidationAndCompilationTime);
+ Assert.Equal(TimeSpan.FromMilliseconds(6), snapshot.PackagePreparationTime);
+ Assert.Equal(TimeSpan.FromMilliseconds(12), snapshot.TotalEvaluationTime);
+ Assert.Equal(TimeSpan.FromMilliseconds(25), snapshot.SubmitToResultTime);
+ Assert.Equal(TimeSpan.FromMilliseconds(15), snapshot.SubmitToPresentTime);
+ Assert.Equal(2, snapshot.CompilerInvocationCount);
+ Assert.Equal(1, evaluator.MaximumConcurrentEvaluations);
+ }
+
+ private static TrackAuthoringEvaluationCoordinator CreateBackground(
+ TrackAuthoringSession session,
+ ControlledEvaluator evaluator,
+ IAuthoringEvaluationClock? clock = null)
+ {
+ return new TrackAuthoringEvaluationCoordinator(
+ session,
+ new AuthoringEvaluationSchedulerOptions(
+ AuthoringEvaluationExecutionMode.SerializedBackground,
+ TimeSpan.Zero),
+ evaluator,
+ clock ?? new FakeEvaluationClock());
+ }
+
+ private static TrackAuthoringSession CreateSession() =>
+ new TrackAuthoringSession(CreatePreparedState(20.0));
+
+ private static PreparedTrackGraphState CreatePreparedState(double radius)
+ {
+ var graph = new TrackAuthoringGraph(
+ new TrackAuthoringGraphNode[]
+ {
+ new TrackAuthoringGraphNode(new StraightSectionDefinition("entry", 5.0)),
+ new TrackAuthoringGraphNode(
+ new ConstantCurvatureSectionDefinition("turn", 8.0, radius))
+ },
+ new[] { new TrackAuthoringGraphEdge("entry", "turn") });
+ return PreparedTrackGraphState.Prepare(
+ graph,
+ new TrackLayoutPackageV2GraphAncillaryState(
+ TrackLayoutPackageV2Dto.ContractName,
+ TrackLayoutPackageV2Dto.ContractVersion,
+ "meters",
+ "M167.3 self-authored scheduler test",
+ "m167-3-scheduler-test",
+ heartlineOffset: null));
+ }
+
+ private static InteractiveAuthoringTransaction Begin(TrackAuthoringSession session) =>
+ session.BeginTransaction("turn", "radius", "Edit turn radius");
+
+ private static TrackAuthoringCandidateOperation RadiusOperation(double radius) =>
+ TrackAuthoringCandidateOperation.Replace(
+ "turn",
+ new ConstantCurvatureSectionDefinition("turn", 8.0, radius));
+
+ private static double Radius(TrackAuthoringGraph graph) =>
+ Assert.IsType(
+ graph.Nodes.Single(node => node.Id == "turn").Section).Radius;
+
+ private sealed class DisconnectedRouteOperation : ITrackAuthoringCandidateOperation
+ {
+ public string OperationTypeId => "test.disconnectRoute";
+
+ public TrackAuthoringGraph Apply(TrackAuthoringGraph sourceGraph) =>
+ new TrackAuthoringGraph(
+ sourceGraph.Nodes,
+ Array.Empty(),
+ sourceGraph.StartPose,
+ sourceGraph.Banking);
+ }
+
+ private sealed class ControlledEvaluator : ITrackCandidateEvaluator
+ {
+ private readonly object gate = new object();
+ private readonly List invocations = new List();
+ private readonly HashSet faultedInvocationIndexes;
+ private readonly SemaphoreSlim invocationSignal = new SemaphoreSlim(0);
+ private int activeEvaluations;
+
+ internal ControlledEvaluator(IEnumerable? faultedInvocationIndexes = null)
+ {
+ this.faultedInvocationIndexes = faultedInvocationIndexes is null
+ ? new HashSet()
+ : new HashSet(faultedInvocationIndexes);
+ }
+
+ internal int InvocationCount
+ {
+ get { lock (gate) { return invocations.Count; } }
+ }
+
+ internal int MaximumConcurrentEvaluations { get; private set; }
+
+ public async Task EvaluateAsync(
+ AuthoringEvaluationRequest request,
+ CancellationToken cancellationToken)
+ {
+ Invocation invocation;
+ int index;
+ lock (gate)
+ {
+ index = invocations.Count;
+ invocation = new Invocation(request);
+ invocations.Add(invocation);
+ activeEvaluations++;
+ MaximumConcurrentEvaluations =
+ System.Math.Max(MaximumConcurrentEvaluations, activeEvaluations);
+ }
+
+ invocationSignal.Release();
+ try
+ {
+ // Intentionally ignores cancellation while held. This models the
+ // current non-cooperative compiler boundary.
+ await invocation.Release.Task.ConfigureAwait(false);
+ if (faultedInvocationIndexes.Contains(index))
+ {
+ throw new InvalidOperationException("Controlled evaluator fault.");
+ }
+
+ TrackCandidateEvaluationProduct production =
+ await new ProductionTrackCandidateEvaluator().EvaluateAsync(
+ request,
+ CancellationToken.None).ConfigureAwait(false);
+ return new TrackCandidateEvaluationProduct(
+ production.Candidate,
+ TimeSpan.FromMilliseconds(1),
+ TimeSpan.FromMilliseconds(2),
+ TimeSpan.FromMilliseconds(3),
+ TimeSpan.FromMilliseconds(6),
+ production.CompilerInvocationCount);
+ }
+ finally
+ {
+ lock (gate)
+ {
+ activeEvaluations--;
+ }
+ }
+ }
+
+ internal AuthoringEvaluationRequest RequestAt(int index)
+ {
+ lock (gate)
+ {
+ return invocations[index].Request;
+ }
+ }
+
+ internal void Release(int index)
+ {
+ lock (gate)
+ {
+ invocations[index].Release.TrySetResult(true);
+ }
+ }
+
+ internal async Task WaitForInvocationAsync(int index)
+ {
+ while (true)
+ {
+ lock (gate)
+ {
+ if (invocations.Count > index)
+ {
+ return;
+ }
+ }
+
+ bool signalled = await invocationSignal.WaitAsync(TimeSpan.FromSeconds(10));
+ Assert.True(signalled, $"Timed out waiting for evaluator invocation {index}.");
+ }
+ }
+
+ private sealed class Invocation
+ {
+ internal Invocation(AuthoringEvaluationRequest request)
+ {
+ Request = request;
+ Release = new TaskCompletionSource(
+ TaskCreationOptions.RunContinuationsAsynchronously);
+ }
+
+ internal AuthoringEvaluationRequest Request { get; }
+
+ internal TaskCompletionSource Release { get; }
+ }
+ }
+
+ private sealed class FakeEvaluationClock : IAuthoringEvaluationClock
+ {
+ private readonly object gate = new object();
+ private readonly List delays = new List();
+ private readonly SemaphoreSlim delaySignal = new SemaphoreSlim(0);
+ private DateTimeOffset utcNow = DateTimeOffset.UnixEpoch;
+
+ public DateTimeOffset UtcNow
+ {
+ get { lock (gate) { return utcNow; } }
+ }
+
+ public Task Delay(TimeSpan delay, CancellationToken cancellationToken)
+ {
+ if (delay <= TimeSpan.Zero)
+ {
+ return Task.CompletedTask;
+ }
+
+ ScheduledDelay scheduled;
+ lock (gate)
+ {
+ scheduled = new ScheduledDelay(utcNow + delay, cancellationToken);
+ delays.Add(scheduled);
+ }
+
+ delaySignal.Release();
+ return scheduled.Completion.Task;
+ }
+
+ internal void Advance(TimeSpan elapsed)
+ {
+ List due;
+ lock (gate)
+ {
+ utcNow += elapsed;
+ due = delays.Where(delay => delay.DueAtUtc <= utcNow).ToList();
+ delays.RemoveAll(delay => delay.DueAtUtc <= utcNow);
+ }
+
+ foreach (ScheduledDelay delay in due)
+ {
+ delay.Completion.TrySetResult(true);
+ }
+ }
+
+ internal async Task WaitForDelayAsync()
+ {
+ lock (gate)
+ {
+ if (delays.Count != 0)
+ {
+ return;
+ }
+ }
+
+ bool signalled = await delaySignal.WaitAsync(TimeSpan.FromSeconds(10));
+ Assert.True(signalled, "Timed out waiting for a throttle delay.");
+ }
+
+ private sealed class ScheduledDelay
+ {
+ internal ScheduledDelay(
+ DateTimeOffset dueAtUtc,
+ CancellationToken cancellationToken)
+ {
+ DueAtUtc = dueAtUtc;
+ Completion = new TaskCompletionSource(
+ TaskCreationOptions.RunContinuationsAsynchronously);
+ if (cancellationToken.CanBeCanceled)
+ {
+ cancellationToken.Register(
+ state => ((TaskCompletionSource)state!).TrySetCanceled(),
+ Completion);
+ }
+ }
+
+ internal DateTimeOffset DueAtUtc { get; }
+
+ internal TaskCompletionSource Completion { get; }
+ }
+ }
+}
diff --git a/Quantum.Track/Authoring/Graph/TrackAuthoringCandidateEvaluation.cs b/Quantum.Track/Authoring/Graph/TrackAuthoringCandidateEvaluation.cs
index 6a6b9d5..b4412b2 100644
--- a/Quantum.Track/Authoring/Graph/TrackAuthoringCandidateEvaluation.cs
+++ b/Quantum.Track/Authoring/Graph/TrackAuthoringCandidateEvaluation.cs
@@ -16,7 +16,9 @@ internal TrackAuthoringCandidateEvaluation(
TrackAuthoringGraph? candidateGraph,
TrackAuthoringGraphRouteResult? routeResult,
TrackAuthoringGraphCompileResult? compileResult,
- IEnumerable diagnostics)
+ IEnumerable diagnostics,
+ TimeSpan candidateApplicationElapsed,
+ TimeSpan validationAndCompilationElapsed)
{
SourceGraph = sourceGraph ?? throw new ArgumentNullException(nameof(sourceGraph));
Operation = operation ?? throw new ArgumentNullException(nameof(operation));
@@ -24,6 +26,8 @@ internal TrackAuthoringCandidateEvaluation(
RouteResult = routeResult;
CompileResult = compileResult;
Compilation = compileResult?.Compilation;
+ CandidateApplicationElapsed = candidateApplicationElapsed;
+ ValidationAndCompilationElapsed = validationAndCompilationElapsed;
_diagnostics = new List(
diagnostics ?? throw new ArgumentNullException(nameof(diagnostics))).AsReadOnly();
}
@@ -42,6 +46,10 @@ internal TrackAuthoringCandidateEvaluation(
public IReadOnlyList Diagnostics => _diagnostics;
+ public TimeSpan CandidateApplicationElapsed { get; }
+
+ public TimeSpan ValidationAndCompilationElapsed { get; }
+
public bool CommitEligible =>
CandidateGraph != null &&
RouteResult?.Success == true &&
diff --git a/Quantum.Track/Authoring/Graph/TrackAuthoringCandidateEvaluator.cs b/Quantum.Track/Authoring/Graph/TrackAuthoringCandidateEvaluator.cs
index 0048c7f..d784d66 100644
--- a/Quantum.Track/Authoring/Graph/TrackAuthoringCandidateEvaluator.cs
+++ b/Quantum.Track/Authoring/Graph/TrackAuthoringCandidateEvaluator.cs
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
+using System.Diagnostics;
namespace Quantum.Track.Authoring
{
@@ -36,6 +37,7 @@ internal static TrackAuthoringCandidateEvaluation Evaluate(
}
TrackAuthoringGraph candidateGraph;
+ var applicationStopwatch = Stopwatch.StartNew();
try
{
candidateGraph = operation.Apply(sourceGraph) ??
@@ -47,6 +49,7 @@ exception is ArgumentException ||
exception is InvalidOperationException ||
exception is NotSupportedException)
{
+ applicationStopwatch.Stop();
var operationDiagnostic = new TrackAuthoringGraphDiagnostic(
TrackAuthoringGraphDiagnosticCode.CandidateOperationFailed,
$"Candidate operation '{operation.OperationTypeId}' failed: {exception.Message}");
@@ -56,36 +59,48 @@ exception is InvalidOperationException ||
null,
null,
null,
- new[] { operationDiagnostic });
+ new[] { operationDiagnostic },
+ applicationStopwatch.Elapsed,
+ TimeSpan.Zero);
}
+ applicationStopwatch.Stop();
+ var validationStopwatch = Stopwatch.StartNew();
+
TrackAuthoringGraphRouteResult routeResult =
TrackAuthoringGraphRouteValidator.Validate(candidateGraph);
if (!routeResult.Success)
{
+ validationStopwatch.Stop();
return new TrackAuthoringCandidateEvaluation(
sourceGraph,
operation,
candidateGraph,
routeResult,
null,
- routeResult.Diagnostics);
+ routeResult.Diagnostics,
+ applicationStopwatch.Elapsed,
+ validationStopwatch.Elapsed);
}
// Empty is a valid editor/session state even though it is intentionally not
// a compilable or persistable track package.
if (candidateGraph.Nodes.Count == 0)
{
+ validationStopwatch.Stop();
return new TrackAuthoringCandidateEvaluation(
sourceGraph,
operation,
candidateGraph,
routeResult,
null,
- Array.Empty());
+ Array.Empty(),
+ applicationStopwatch.Elapsed,
+ validationStopwatch.Elapsed);
}
TrackAuthoringGraphCompileResult compileResult = compiler(candidateGraph);
+ validationStopwatch.Stop();
IReadOnlyList diagnostics = compileResult.Diagnostics;
return new TrackAuthoringCandidateEvaluation(
sourceGraph,
@@ -93,7 +108,9 @@ exception is InvalidOperationException ||
candidateGraph,
routeResult,
compileResult,
- diagnostics);
+ diagnostics,
+ applicationStopwatch.Elapsed,
+ validationStopwatch.Elapsed);
}
}
}
From 7d8ee86eec90742bc26ac6f043b198ea1d7e8157 Mon Sep 17 00:00:00 2001
From: Coasterpete <83011124+Coasterpete@users.noreply.github.com>
Date: Wed, 22 Jul 2026 16:29:40 -0400
Subject: [PATCH 07/11] docs(editor): document M167.3 scheduling contract
---
...167-3-latest-wins-evaluation-scheduling.md | 184 ++++++++++++++++++
1 file changed, 184 insertions(+)
create mode 100644 docs/editor/m167-3-latest-wins-evaluation-scheduling.md
diff --git a/docs/editor/m167-3-latest-wins-evaluation-scheduling.md b/docs/editor/m167-3-latest-wins-evaluation-scheduling.md
new file mode 100644
index 0000000..0ed5e08
--- /dev/null
+++ b/docs/editor/m167-3-latest-wins-evaluation-scheduling.md
@@ -0,0 +1,184 @@
+# M167.3 Latest-Wins Evaluation Scheduling
+
+## Scope
+
+M167.3 adds a thin frontend-neutral coordinator above `TrackAuthoringSession`.
+It does not add Inspector scrubbing, pointer handling, viewport integration,
+selection remapping, force authoring, incremental compilation, parallel
+compilation, or a general job system.
+
+`TrackAuthoringSession` remains the sole authority for committed and presented
+state, dirty state, persistence, history, current candidate identity, and exact
+commit eligibility. The coordinator owns only queued/running work, the
+one-latest-pending policy, cancellation hints, throttling, completion tasks, and
+instance metrics.
+
+## Contract
+
+The public scheduling surface is:
+
+- `TrackAuthoringEvaluationCoordinator`;
+- `AuthoringEvaluationSchedulerOptions`;
+- `AuthoringEvaluationSubmission`, containing an immutable request and completion
+ task;
+- `AuthoringEvaluationRequest`, containing session, committed base, transaction,
+ provisional, and scheduling-request identities;
+- `AuthoringEvaluationOutcome`, containing status, candidate when available,
+ diagnostics, fault summary, and timings;
+- `AuthoringScheduledCommitResult`; and
+- `EvaluationSchedulerSnapshot`.
+
+`SubmitProvisionalEdit` first asks the session to reserve the next provisional
+revision against the transaction's captured before-state. Evaluation later uses
+that immutable graph and ancillary-state snapshot. Publication returns to the
+session, which accepts the candidate only if all of these still match:
+
+1. authoring session ID;
+2. committed base revision;
+3. transaction revision;
+4. newest provisional edit revision;
+5. exact captured source graph and ancillary state.
+
+Existing `TrackAuthoringSession.SubmitCandidate` remains synchronous. It now uses
+the same reserve/evaluate/publish split, so synchronous and scheduled paths share
+candidate construction and exact acceptance semantics.
+
+## Scheduling state
+
+```mermaid
+stateDiagram-v2
+ [*] --> Idle
+ Idle --> Running: submit leading revision
+ Running --> RunningWithPending: submit trailing revision
+ RunningWithPending --> RunningWithPending: newer submit replaces pending
+ Running --> Idle: completion and no pending
+ RunningWithPending --> Running: completion; start latest pending
+ Running --> Idle: cancel or stale completion
+ RunningWithPending --> Running: pending cancelled/coalesced
+ Idle --> [*]: dispose
+ Running --> [*]: dispose after running work returns
+```
+
+Serialized background mode never has more than one evaluator invocation running.
+It retains at most one pending request. Replacing that pending request produces a
+`Coalesced` outcome without starting evaluation. The leading request starts
+immediately. A configurable minimum interval limits subsequent starts while still
+allowing continuous periodic feedback; it is not a pause-only debounce.
+
+The background preset uses a configurable initial maximum rate of 30 evaluations
+per second. A caller may provide another interval, including zero. The final
+revision frozen for commit bypasses the interval.
+
+## Exact final-revision commit
+
+`CommitLatestAsync` freezes the active transaction's newest reserved provisional
+revision:
+
+- if its exact valid or invalid candidate is already published, the session's
+ existing `Commit` method is called immediately;
+- if it is pending or running, the coordinator retains that work, marks it
+ final-priority, and awaits its completion without blocking the caller thread;
+- submissions to that transaction are rejected while the revision is frozen;
+- an accepted valid candidate commits without reevaluation or compilation;
+- an accepted invalid candidate produces the session's structured
+ `CandidateRejected` commit result; and
+- cancellation, staleness, coalescing, or evaluator fault completes the wait
+ without calling `Commit`.
+
+The older last-good presented candidate is never substituted for the frozen
+revision. A successful changed commit still creates exactly one history entry.
+
+## Cancellation and invalidation
+
+Cancellation is an optimization boundary, not the correctness mechanism.
+Cancellation is checked:
+
+- before reservation/candidate construction where a caller token is already
+ cancelled;
+- before evaluator invocation;
+- after graph application/validation/compilation returns;
+- before package preparation returns a candidate; and
+- before publication.
+
+The current compiler has no cooperative cancellation parameter. A compile already
+in progress may finish. Transaction cancel calls the session immediately to restore
+committed presentation and cancels running/pending scheduler tokens. Open/new
+session replacement changes `AuthoringSessionId`. In both cases a late result is
+incapable of publication because the session's structural acceptance check fails.
+
+A scheduler/evaluator exception produces a `Faulted` outcome containing request
+identity, phase, exception type/message, and timing. It does not close or replace
+the session.
+
+## Execution mode and thread-safety statement
+
+Synchronous mode is the default. It is deterministic, preserves the M167.2 calling
+behavior, and is suitable for small routes and tests.
+
+Serialized background mode is opt-in. Repository evidence shows that authoring
+graphs and section definitions copy their inputs, and every successful candidate
+constructs its own graph, compile result, document, runtime, projections, and
+canonical package JSON. Tests establish synchronous/background parity for straight
+and spatial routes, repeatability, and no overlap inside one coordinator. The
+spatial compiler path constructs a per-candidate G-Shark adapter, but this milestone
+does not establish a general thread-safety contract for all external G-Shark code
+or for multiple coordinators compiling concurrently. Background-by-default is
+therefore not justified yet.
+
+The coordinator may publish session state from its serialized worker. Session field
+access and mutation are synchronized, but the coordinator never touches controls,
+dispatchers, renderer state, or document views. A future frontend must await the
+immutable completion outcome and marshal any control/document/viewport work to its
+own UI thread.
+
+## Metrics
+
+Metrics are coordinator-instance-owned. `AuthoringEvaluationTiming` records per
+outcome values, and `EvaluationSchedulerSnapshot` contains cumulative totals.
+
+| Counter | Meaning |
+|---|---|
+| `Submitted` | requests successfully reserved by the session |
+| `Started` | evaluator invocations begun |
+| `Completed` | started evaluator invocations that returned, cancelled, or faulted |
+| `Accepted` | current valid candidates published by the session |
+| `Rejected` | current expected-invalid candidates published for diagnostics |
+| `Stale` | results structurally refused or invalidated before start |
+| `Coalesced` | pending requests replaced by a newer request |
+| `CancelledBeforeStart` | cancelled/coalesced requests that never invoked the evaluator |
+| `Cancelled` | outcomes stopped by a cancellation signal |
+| `Faulted` | unexpected evaluator or publication faults |
+| `MaximumPendingDepth` | observed trailing queue depth; bounded to one |
+
+Timing totals cover queue wait, candidate application, validation/compilation,
+package preparation, total evaluation, submit-to-result, and submit-to-present.
+Compiler invocations are also recorded per outcome and cumulatively; a route-valid,
+non-empty started candidate invokes the graph compiler once. Commit, undo, and redo
+invoke it zero times.
+
+## Verification
+
+Headless controllable-evaluator tests cover synchronous parity, one-running plus
+one-latest-pending behavior, coalescing, stale valid/invalid isolation, cancellation
+before and during non-cooperative work, transaction/session invalidation, structured
+faults, exact commit waiting, last-good rejection, final-priority throttle bypass,
+single-worker execution, and deterministic fake-clock metrics.
+
+Production tests cover straight-section evaluation, repeatability, explicit-banking
+compilation rejection, spatial/G-Shark parity, one compiler invocation per started
+candidate, zero-compilation commit, and non-overlapping production candidate work.
+Dependency coverage rejects frontend and renderer references from
+`Quantum.Application`.
+
+## Recommended M167.4 integration point
+
+Keep `EditorWorkspace.ApplyGraphEdit` as the existing one-shot path. Add one
+`TrackAuthoringSession` and one opt-in coordinator per active track document at the
+document/workspace service boundary. In `InspectorPaneControl`, begin a transaction
+when a numeric scrub gesture starts, submit immutable absolute replacement
+operations on value changes, and await `AuthoringEvaluationSubmission.Completion`.
+On the Avalonia dispatcher, apply only an accepted current session snapshot to the
+document projections and viewport. On gesture completion call `CommitLatestAsync`;
+on escape, pointer loss, document close, open, or new, call transaction cancellation
+or session replacement before updating controls. The coordinator itself must remain
+unaware of Avalonia.
From 5766f30337675b46a9154d10c1448c269ee77043 Mon Sep 17 00:00:00 2001
From: Coasterpete <83011124+Coasterpete@users.noreply.github.com>
Date: Wed, 22 Jul 2026 16:59:34 -0400
Subject: [PATCH 08/11] feat(editor): integrate live straight-length session
---
.../Authoring/StraightLengthEditState.cs | 90 +++
.../Services/EditorWorkspace.cs | 542 +++++++++++++++++-
.../Editor/M167LiveEditWorkspaceTests.cs | 234 ++++++++
3 files changed, 840 insertions(+), 26 deletions(-)
create mode 100644 Quantum.Editor.Avalonia/Services/Authoring/StraightLengthEditState.cs
create mode 100644 Quantum.Tests/Editor/M167LiveEditWorkspaceTests.cs
diff --git a/Quantum.Editor.Avalonia/Services/Authoring/StraightLengthEditState.cs b/Quantum.Editor.Avalonia/Services/Authoring/StraightLengthEditState.cs
new file mode 100644
index 0000000..1a57d6c
--- /dev/null
+++ b/Quantum.Editor.Avalonia/Services/Authoring/StraightLengthEditState.cs
@@ -0,0 +1,90 @@
+using Quantum.Application.Authoring;
+using Quantum.Track.Authoring;
+
+namespace Quantum.Editor.Avalonia.Services.Authoring;
+
+public enum StraightLengthEditStatus
+{
+ Ready,
+ Evaluating,
+ Accepted,
+ Invalid
+}
+
+///
+/// Frontend-facing immutable state for the one M167.4 live parameter.
+/// The application session remains authoritative for graph presentation and commit.
+///
+public sealed record StraightLengthEditState(
+ TransactionRevision TransactionRevision,
+ PreparedTrackGraphState BeforeState,
+ string NodeId,
+ double CommittedLength,
+ double RawLength,
+ double? AcceptedPreviewLength,
+ StraightLengthEditStatus Status,
+ IReadOnlyList Diagnostics,
+ long RawPointerUpdates,
+ long AcceptedPreviews,
+ TimeSpan FinalCommitWait)
+{
+ public bool IsInvalid => Status == StraightLengthEditStatus.Invalid;
+
+ public string StatusText => Status switch
+ {
+ StraightLengthEditStatus.Evaluating => "Evaluating preview…",
+ StraightLengthEditStatus.Accepted => "Live preview",
+ StraightLengthEditStatus.Invalid =>
+ "Last valid preview — current value is invalid",
+ _ => "Drag horizontally to edit length"
+ };
+}
+
+public sealed record StraightLengthInteractionMetrics(
+ long RawPointerUpdates,
+ long SubmittedEvaluations,
+ long CoalescedUpdates,
+ long StartedEvaluations,
+ long AcceptedPreviews,
+ long StaleCompletions,
+ TimeSpan SubmitToPresentLatency,
+ TimeSpan FinalCommitWaitLatency,
+ int CompilerInvocationCount);
+
+///
+/// Defers construction of the immutable straight definition until candidate
+/// evaluation so an invalid raw value still reserves a newer provisional revision.
+///
+internal sealed class SetStraightLengthCandidateOperation : ITrackAuthoringCandidateOperation
+{
+ internal SetStraightLengthCandidateOperation(string nodeId, double absoluteLength)
+ {
+ NodeId = string.IsNullOrWhiteSpace(nodeId)
+ ? throw new ArgumentException("A straight node ID is required.", nameof(nodeId))
+ : nodeId;
+ AbsoluteLength = absoluteLength;
+ }
+
+ public string OperationTypeId => "straight.setAbsoluteLength";
+
+ internal string NodeId { get; }
+
+ internal double AbsoluteLength { get; }
+
+ public TrackAuthoringGraph Apply(TrackAuthoringGraph sourceGraph)
+ {
+ ArgumentNullException.ThrowIfNull(sourceGraph);
+ TrackAuthoringGraphNode node = sourceGraph.Nodes.Single(candidate =>
+ string.Equals(candidate.Id, NodeId, StringComparison.Ordinal));
+ StraightSectionDefinition straight = node.Section as StraightSectionDefinition ??
+ throw new InvalidOperationException(
+ $"Graph node '{NodeId}' is not a straight section.");
+ return TrackAuthoringGraphOperations.Replace(
+ sourceGraph,
+ NodeId,
+ new StraightSectionDefinition(
+ straight.Id,
+ AbsoluteLength,
+ straight.RollRadians));
+ }
+}
diff --git a/Quantum.Editor.Avalonia/Services/EditorWorkspace.cs b/Quantum.Editor.Avalonia/Services/EditorWorkspace.cs
index b08678f..faf359d 100644
--- a/Quantum.Editor.Avalonia/Services/EditorWorkspace.cs
+++ b/Quantum.Editor.Avalonia/Services/EditorWorkspace.cs
@@ -1,6 +1,7 @@
using System.Globalization;
using Quantum.Application.Authoring;
using Quantum.Editor.Avalonia.Models;
+using Quantum.Editor.Avalonia.Services.Authoring;
using Quantum.Editor.Avalonia.Services.Commands;
using Quantum.Editor.Avalonia.Services.Documents;
using Quantum.Editor.Avalonia.Services.Selection;
@@ -15,11 +16,19 @@
namespace Quantum.Editor.Avalonia.Services;
-public sealed class EditorWorkspace
+public sealed class EditorWorkspace : IDisposable
{
private readonly TrackDocumentFileService fileService;
private readonly TrackSamplingService samplingService;
private TrackEditorDocument? observedDocument;
+ private TrackAuthoringSession? authoringSession;
+ private TrackAuthoringEvaluationCoordinator? evaluationCoordinator;
+ private StraightLengthEditState? straightLengthEdit;
+ private bool applyingSessionCommitToDocument;
+ private bool disposed;
+ private long straightLengthRawPointerUpdates;
+ private long straightLengthAcceptedPreviews;
+ private TimeSpan straightLengthFinalCommitWait;
private TrackAuthoringCompilation? projectedCompilation;
@@ -75,6 +84,16 @@ public EditorWorkspace(
public TrackEditorDocument? ActiveDocument => Documents.ActiveDocument as TrackEditorDocument;
+ public TrackAuthoringSession? AuthoringSession => authoringSession;
+
+ public TrackAuthoringEvaluationCoordinator? EvaluationCoordinator => evaluationCoordinator;
+
+ public PreparedTrackGraphState? PresentedState => authoringSession?.PresentedState;
+
+ public StraightLengthEditState? StraightLengthEdit => straightLengthEdit;
+
+ public bool IsInteractiveEditActive => straightLengthEdit is not null;
+
public EditorSelection? CurrentSelection =>
Selection.SelectedItems.Count == 0
? null
@@ -106,6 +125,7 @@ public EditorWorkspace(
public TrackEditorDocument NewDocument()
{
+ CancelStraightLengthEdit();
TrackEditorDocument document = TrackEditorDocument.CreateEmpty(
"Untitled Layout",
"Untitled Layout");
@@ -116,6 +136,7 @@ public TrackEditorDocument NewDocument()
public TrackEditorDocument OpenDocument(string filePath)
{
+ CancelStraightLengthEdit();
TrackEditorDocument document = fileService.Open(filePath);
ActivateDocument(document, $"Opened {document.FilePath}.");
return document;
@@ -123,6 +144,7 @@ public TrackEditorDocument OpenDocument(string filePath)
public void SaveDocument(string? filePath = null)
{
+ ThrowIfInteractiveEditActive("Save is disabled while a live edit is active.");
TrackEditorDocument document = ActiveDocument ??
throw new InvalidOperationException("There is no active track document to save.");
fileService.Save(document, filePath);
@@ -135,6 +157,12 @@ public bool ApplyGraphEdit(
{
ArgumentNullException.ThrowIfNull(edit);
+ if (IsInteractiveEditActive)
+ {
+ SetStatus("Source edits are disabled while a live edit is active.");
+ return false;
+ }
+
TrackEditorDocument? document = ActiveDocument;
if (document?.Graph is null)
{
@@ -416,6 +444,12 @@ exception is InvalidOperationException ||
public void Select(EditorSelection? selection)
{
+ if (IsInteractiveEditActive && !MatchesActiveStraightSelection(selection))
+ {
+ SetStatus("Selection changes are disabled while a live edit is active.");
+ return;
+ }
+
if (selection is null)
{
Selection.Clear();
@@ -496,6 +530,12 @@ public void SetHoveredSection(int? sectionIndex)
public bool UndoLast()
{
+ if (IsInteractiveEditActive)
+ {
+ SetStatus("Undo is disabled while a live edit is active.");
+ return false;
+ }
+
string? description = UndoRedo.UndoDescription;
bool result = UndoRedo.Undo();
if (result)
@@ -508,6 +548,12 @@ public bool UndoLast()
public bool RedoLast()
{
+ if (IsInteractiveEditActive)
+ {
+ SetStatus("Redo is disabled while a live edit is active.");
+ return false;
+ }
+
string? description = UndoRedo.RedoDescription;
bool result = UndoRedo.Redo();
if (result)
@@ -524,8 +570,255 @@ public void SetStatus(string message)
WorkspaceChanged?.Invoke(this, EventArgs.Empty);
}
+ public bool BeginStraightLengthEdit(string nodeId)
+ {
+ ThrowIfDisposed();
+ if (straightLengthEdit is not null)
+ {
+ return false;
+ }
+
+ TrackAuthoringSession session = authoringSession ??
+ throw new InvalidOperationException("The active document has no authoring session.");
+ TrackAuthoringGraphNode node = session.CommittedState.SourceGraph.Nodes
+ .Single(candidate => string.Equals(candidate.Id, nodeId, StringComparison.Ordinal));
+ StraightSectionDefinition straight = node.Section as StraightSectionDefinition ??
+ throw new InvalidOperationException(
+ $"Graph node '{nodeId}' is not a straight section.");
+ InteractiveAuthoringTransaction transaction = session.BeginTransaction(
+ nodeId,
+ "StraightSectionDefinition.Length",
+ $"Edit straight length {nodeId}");
+ straightLengthEdit = new StraightLengthEditState(
+ transaction.Revision,
+ transaction.BeforeState,
+ nodeId,
+ straight.Length,
+ straight.Length,
+ straight.Length,
+ StraightLengthEditStatus.Ready,
+ Array.Empty(),
+ RawPointerUpdates: 0,
+ AcceptedPreviews: 0,
+ FinalCommitWait: TimeSpan.Zero);
+ straightLengthRawPointerUpdates = 0;
+ straightLengthAcceptedPreviews = 0;
+ straightLengthFinalCommitWait = TimeSpan.Zero;
+ SetStatus($"Editing straight length {nodeId}.");
+ return true;
+ }
+
+ public void RecordStraightLengthPointerUpdate()
+ {
+ if (straightLengthEdit is not null)
+ {
+ straightLengthRawPointerUpdates++;
+ straightLengthEdit = straightLengthEdit with
+ {
+ RawPointerUpdates = straightLengthEdit.RawPointerUpdates + 1
+ };
+ }
+ }
+
+ public AuthoringEvaluationSubmission SubmitStraightLengthEdit(double absoluteLength)
+ {
+ ThrowIfDisposed();
+ StraightLengthEditState edit = straightLengthEdit ??
+ throw new InvalidOperationException("There is no active straight-length edit.");
+ TrackAuthoringEvaluationCoordinator coordinator = evaluationCoordinator ??
+ throw new InvalidOperationException("The active document has no evaluation coordinator.");
+
+ AuthoringEvaluationSubmission submission = coordinator.SubmitProvisionalEdit(
+ edit.TransactionRevision,
+ new SetStraightLengthCandidateOperation(edit.NodeId, absoluteLength));
+ bool rawInvalid = !double.IsFinite(absoluteLength) || absoluteLength <= 0.0;
+ straightLengthEdit = edit with
+ {
+ RawLength = absoluteLength,
+ Status = rawInvalid
+ ? StraightLengthEditStatus.Invalid
+ : StraightLengthEditStatus.Evaluating,
+ Diagnostics = rawInvalid
+ ? new[] { "Section length must be finite and greater than zero." }
+ : Array.Empty()
+ };
+ SetStatus(straightLengthEdit.StatusText);
+ return submission;
+ }
+
+ ///
+ /// Called by the Avalonia dispatcher after an immutable completion is awaited.
+ /// The outcome never supplies presentation directly; the session is re-read and
+ /// remains the sole freshness authority.
+ ///
+ public bool PublishStraightLengthOutcome(AuthoringEvaluationOutcome outcome)
+ {
+ ArgumentNullException.ThrowIfNull(outcome);
+ StraightLengthEditState? edit = straightLengthEdit;
+ TrackAuthoringSession? session = authoringSession;
+ if (edit is null || session is null ||
+ outcome.Request.TransactionRevision != edit.TransactionRevision)
+ {
+ return false;
+ }
+
+ InteractiveAuthoringTransaction? transaction = session.ActiveTransaction;
+ if (transaction is null || transaction.Revision != edit.TransactionRevision ||
+ transaction.NewestProvisionalRevision != outcome.Request.ProvisionalEditRevision)
+ {
+ return false;
+ }
+
+ if (outcome.Status == AuthoringEvaluationOutcomeStatus.Accepted &&
+ outcome.Candidate?.PreparedState is not null &&
+ ReferenceEquals(session.PresentedState, outcome.Candidate.PreparedState))
+ {
+ PreparedTrackGraphState presented = session.PresentedState;
+ double acceptedLength = GetStraightLength(presented.Graph, edit.NodeId);
+ straightLengthEdit = edit with
+ {
+ AcceptedPreviewLength = acceptedLength,
+ Status = StraightLengthEditStatus.Accepted,
+ Diagnostics = Array.Empty(),
+ AcceptedPreviews = edit.AcceptedPreviews + 1
+ };
+ straightLengthAcceptedPreviews++;
+ ProjectPreparedState(presented);
+ SetStatus($"Live preview: {acceptedLength:0.######} m.");
+ return true;
+ }
+
+ if (outcome.Status == AuthoringEvaluationOutcomeStatus.Rejected)
+ {
+ IReadOnlyList diagnostics = BuildOutcomeDiagnostics(outcome);
+ straightLengthEdit = edit with
+ {
+ Status = StraightLengthEditStatus.Invalid,
+ Diagnostics = diagnostics
+ };
+ SetStatus("Last valid preview — current value is invalid. " +
+ string.Join(" ", diagnostics));
+ return true;
+ }
+
+ if (outcome.Status == AuthoringEvaluationOutcomeStatus.Faulted)
+ {
+ string message = outcome.Fault?.Message ?? "Preview evaluation faulted.";
+ straightLengthEdit = edit with
+ {
+ Status = StraightLengthEditStatus.Invalid,
+ Diagnostics = new[] { message }
+ };
+ SetStatus("Last valid preview — current value is invalid. " + message);
+ return true;
+ }
+
+ return false;
+ }
+
+ public async Task CommitStraightLengthEditAsync(
+ CancellationToken cancellationToken = default)
+ {
+ ThrowIfDisposed();
+ StraightLengthEditState? edit = straightLengthEdit;
+ TrackAuthoringEvaluationCoordinator? coordinator = evaluationCoordinator;
+ if (edit is null || coordinator is null)
+ {
+ return null;
+ }
+
+ var stopwatch = System.Diagnostics.Stopwatch.StartNew();
+ AuthoringScheduledCommitResult result = await coordinator.CommitLatestAsync(
+ edit.TransactionRevision,
+ cancellationToken);
+ stopwatch.Stop();
+ if (!ReferenceEquals(edit, straightLengthEdit) &&
+ straightLengthEdit?.TransactionRevision != edit.TransactionRevision)
+ {
+ return result;
+ }
+
+ edit = straightLengthEdit ?? edit;
+ straightLengthFinalCommitWait = stopwatch.Elapsed;
+ straightLengthEdit = edit with { FinalCommitWait = stopwatch.Elapsed };
+ if (result.Succeeded && result.CommitResult?.CommittedState is not null)
+ {
+ AuthoringCommitResult commit = result.CommitResult;
+ PreparedTrackGraphState afterState = commit.CommittedState.PreparedState;
+ if (commit.Changed)
+ {
+ TrackEditorDocument document = ActiveDocument ??
+ throw new InvalidOperationException("The edited document is no longer active.");
+ applyingSessionCommitToDocument = true;
+ try
+ {
+ UndoRedo.Execute(new TrackGraphSnapshotOperation(
+ $"Edit straight length {edit.NodeId}",
+ document,
+ edit.BeforeState,
+ afterState));
+ }
+ finally
+ {
+ applyingSessionCommitToDocument = false;
+ }
+ }
+
+ ProjectPreparedState(afterState);
+ straightLengthEdit = null;
+ SetStatus(commit.Changed
+ ? $"Committed straight length {edit.NodeId}."
+ : "No graph values changed.");
+ return result;
+ }
+
+ coordinator.CancelTransaction(edit.TransactionRevision);
+ ProjectPreparedState(authoringSession!.CommittedState.PreparedState);
+ straightLengthEdit = null;
+ SetStatus("Straight length was not committed; the committed value was restored.");
+ return result;
+ }
+
+ public bool CancelStraightLengthEdit()
+ {
+ StraightLengthEditState? edit = straightLengthEdit;
+ if (edit is null)
+ {
+ return false;
+ }
+
+ bool cancelled = evaluationCoordinator?.CancelTransaction(edit.TransactionRevision) == true;
+ PreparedTrackGraphState? committed = authoringSession?.CommittedState.PreparedState;
+ straightLengthEdit = null;
+ if (committed is not null && ReferenceEquals(observedDocument, ActiveDocument))
+ {
+ ProjectPreparedState(committed);
+ }
+
+ SetStatus("Live straight-length edit cancelled.");
+ return cancelled;
+ }
+
+ public StraightLengthInteractionMetrics CaptureStraightLengthMetrics()
+ {
+ EvaluationSchedulerSnapshot scheduler = evaluationCoordinator?.CaptureSnapshot() ??
+ throw new InvalidOperationException("The active document has no evaluation coordinator.");
+ StraightLengthEditState? edit = straightLengthEdit;
+ return new StraightLengthInteractionMetrics(
+ straightLengthRawPointerUpdates,
+ scheduler.Submitted,
+ scheduler.Coalesced,
+ scheduler.Started,
+ straightLengthAcceptedPreviews,
+ scheduler.Stale,
+ scheduler.SubmitToPresentTime,
+ straightLengthFinalCommitWait,
+ scheduler.CompilerInvocationCount);
+ }
+
private void ActivateDocument(TrackEditorDocument document, string message)
{
+ CancelStraightLengthEdit();
TrackEditorDocument? previousDocument = ActiveDocument;
UndoRedo.Clear();
if (previousDocument != null)
@@ -542,53 +835,94 @@ private void RegisterCommands()
{
Commands.Register(new EditorCommand(
EditorCommandIds.NewDocument,
- _ => NewDocument()));
+ _ => NewDocument(),
+ _ => !IsInteractiveEditActive));
Commands.Register(new EditorCommand(
EditorCommandIds.OpenDocument,
parameter => OpenDocument((string)parameter!),
- parameter => parameter is string path && !string.IsNullOrWhiteSpace(path)));
+ parameter => !IsInteractiveEditActive &&
+ parameter is string path && !string.IsNullOrWhiteSpace(path)));
Commands.Register(new EditorCommand(
EditorCommandIds.SaveDocument,
parameter => SaveDocument(parameter as string),
- _ => ActiveDocument?.CanSave == true));
+ _ => !IsInteractiveEditActive && ActiveDocument?.CanSave == true));
Commands.Register(new EditorCommand(
EditorCommandIds.SaveDocumentAs,
parameter => SaveDocument((string)parameter!),
- parameter => ActiveDocument?.CanSave == true &&
+ parameter => !IsInteractiveEditActive &&
+ ActiveDocument?.CanSave == true &&
parameter is string path &&
!string.IsNullOrWhiteSpace(path)));
Commands.Register(new EditorCommand(
EditorCommandIds.Undo,
_ => UndoLast(),
- _ => UndoRedo.CanUndo));
+ _ => !IsInteractiveEditActive && UndoRedo.CanUndo));
Commands.Register(new EditorCommand(
EditorCommandIds.Redo,
_ => RedoLast(),
- _ => UndoRedo.CanRedo));
+ _ => !IsInteractiveEditActive && UndoRedo.CanRedo));
Commands.Register(new EditorCommand(
EditorCommandIds.Select,
parameter => Select((EditorSelection?)parameter),
- parameter => parameter is EditorSelection));
+ parameter => !IsInteractiveEditActive && parameter is EditorSelection));
}
private void OnActiveDocumentChanged(object? sender, EventArgs eventArgs)
{
+ CancelStraightLengthEdit();
if (observedDocument != null)
{
observedDocument.Changed -= OnDocumentChanged;
}
+ evaluationCoordinator?.Dispose();
+ evaluationCoordinator = null;
+ authoringSession = null;
observedDocument = ActiveDocument;
if (observedDocument != null)
{
observedDocument.Changed += OnDocumentChanged;
+ if (observedDocument.Graph is not null)
+ {
+ authoringSession = new TrackAuthoringSession(
+ observedDocument.CaptureGraphState(),
+ markClean: !observedDocument.IsDirty);
+ evaluationCoordinator = new TrackAuthoringEvaluationCoordinator(
+ authoringSession,
+ AuthoringEvaluationSchedulerOptions.SerializedBackground);
+ }
}
+ projectedCompilation = null;
+ stationCursor = null;
RefreshDocumentProjection();
}
private void OnDocumentChanged(object? sender, EventArgs eventArgs)
{
+ TrackEditorDocument? document = ActiveDocument;
+ if (!applyingSessionCommitToDocument &&
+ straightLengthEdit is null &&
+ document?.Graph is not null &&
+ authoringSession is not null)
+ {
+ PreparedTrackGraphState documentState = document.CaptureGraphState();
+ PreparedTrackGraphState sessionState = authoringSession.CommittedState.PreparedState;
+ if (!ReferenceEquals(documentState.Graph, sessionState.Graph) ||
+ !ReferenceEquals(
+ documentState.GraphCompileResult,
+ sessionState.GraphCompileResult))
+ {
+ authoringSession.ReplaceSessionState(
+ documentState,
+ markClean: !document.IsDirty);
+ }
+ else if (!document.IsDirty)
+ {
+ authoringSession.MarkClean();
+ }
+ }
+
RefreshDocumentProjection();
}
@@ -605,7 +939,8 @@ private void OnUndoRedoStateChanged(object? sender, EventArgs eventArgs)
private void RefreshDocumentProjection()
{
TrackEditorDocument? document = ActiveDocument;
- if (document?.Graph is null || document.GraphCompileResult is null)
+ PreparedTrackGraphState? state = authoringSession?.PresentedState;
+ if (document?.Graph is null || state is null)
{
graphNodes = Array.Empty();
outlinerNodes = Array.Empty();
@@ -617,11 +952,85 @@ private void RefreshDocumentProjection()
}
else
{
- graphNodes = BuildGraphNodes(document.GraphCompileResult.OrderedNodes);
- outlinerNodes = BuildOutliner(document);
- TrackAuthoringCompilation compilation = document.Compilation!;
+ ProjectPreparedState(state, notify: false);
+ }
+
+ NormalizeSelectionAfterProjection();
+ WorkspaceChanged?.Invoke(this, EventArgs.Empty);
+ }
+
+ private void NormalizeSelectionAfterProjection()
+ {
+ EditorSelection? selection = CurrentSelection;
+ if (selection?.Kind == EditorSelectionKind.Sample && stationCursor.HasValue)
+ {
+ int sampleIndex = stationCursor.Value.SampleIndex;
+ if (sampleIndex >= 0 && sampleIndex < viewportSnapshot.Samples.Count)
+ {
+ TrackViewportSample sample = viewportSnapshot.Samples[sampleIndex];
+ string? nodeId = graphNodes
+ .FirstOrDefault(node => node.RouteIndex == sample.SectionIndex)
+ ?.NodeId;
+ EditorSelection replacement = EditorSelection.Sample(
+ sampleIndex,
+ sample.SectionIndex,
+ nodeId);
+ if (replacement != selection)
+ {
+ Selection.SetSelection(new object[] { replacement });
+ }
+ }
+
+ return;
+ }
+
+ if (selection?.Kind != EditorSelectionKind.Section || selection.NodeId is null)
+ {
+ return;
+ }
+
+ EditorGraphNode? currentNode = GraphNodes.FirstOrDefault(node =>
+ string.Equals(node.NodeId, selection.NodeId, StringComparison.Ordinal));
+ if (currentNode is not null)
+ {
+ if (selection.SectionIndex != currentNode.RouteIndex)
+ {
+ Selection.SetSelection(new object[] { currentNode.Selection });
+ }
+
+ return;
+ }
+
+ Select(EditorSelection.Track);
+ }
+
+ private void ProjectPreparedState(
+ PreparedTrackGraphState state,
+ bool notify = true)
+ {
+ ArgumentNullException.ThrowIfNull(state);
+ TrackEditorDocument? document = ActiveDocument;
+ EngineeringStationCursor? previousCursor = stationCursor;
+
+ if (state.GraphCompileResult?.Compilation is not TrackAuthoringCompilation compilation)
+ {
+ graphNodes = Array.Empty();
+ outlinerNodes = Array.Empty();
+ viewportSnapshot = TrackViewportSnapshot.Empty;
+ engineeringSnapshot = null;
+ stationCursor = null;
+ hoveredSectionIndex = -1;
+ projectedCompilation = null;
+ }
+ else
+ {
+ graphNodes = BuildGraphNodes(state.GraphCompileResult.OrderedNodes);
+ outlinerNodes = document is null
+ ? Array.Empty()
+ : BuildOutliner(document, state);
if (!ReferenceEquals(projectedCompilation, compilation))
{
+ double? previousStation = stationCursor?.Station;
projectedCompilation = compilation;
compilationRevision++;
snapshotRevision++;
@@ -632,9 +1041,17 @@ private void RefreshDocumentProjection()
snapshotRevision,
samplingService.GetSampleCount(compilation.TotalLength)));
viewportSnapshot = samplingService.CreateViewportSnapshot(engineeringSnapshot);
- stationCursor = new EngineeringStationCursor(
- 0,
- engineeringSnapshot.StationGrid[0]);
+ double remappedStation = previousStation.HasValue
+ ? System.Math.Clamp(previousStation.Value, 0.0, engineeringSnapshot.TotalLength)
+ : 0.0;
+ int remappedIndex = EngineeringSnapshotNavigation.FindNearestSampleIndex(
+ engineeringSnapshot,
+ remappedStation);
+ stationCursor = remappedIndex < 0
+ ? null
+ : new EngineeringStationCursor(
+ remappedIndex,
+ engineeringSnapshot.StationGrid[remappedIndex]);
}
if (hoveredSectionIndex >= graphNodes.Count)
@@ -644,24 +1061,94 @@ private void RefreshDocumentProjection()
}
NormalizeSelectionAfterProjection();
+ if (!notify)
+ {
+ return;
+ }
+
+ if (previousCursor != stationCursor)
+ {
+ StationCursorChanged?.Invoke(this, EventArgs.Empty);
+ }
+
WorkspaceChanged?.Invoke(this, EventArgs.Empty);
}
- private void NormalizeSelectionAfterProjection()
+ private bool MatchesActiveStraightSelection(EditorSelection? selection)
{
- EditorSelection? selection = CurrentSelection;
- if (selection?.Kind != EditorSelectionKind.Section || selection.NodeId is null)
+ return straightLengthEdit is not null &&
+ selection?.Kind == EditorSelectionKind.Section &&
+ string.Equals(
+ selection.NodeId,
+ straightLengthEdit.NodeId,
+ StringComparison.Ordinal);
+ }
+
+ private static double GetStraightLength(TrackAuthoringGraph graph, string nodeId)
+ {
+ TrackAuthoringGraphNode node = graph.Nodes.Single(candidate =>
+ string.Equals(candidate.Id, nodeId, StringComparison.Ordinal));
+ return (node.Section as StraightSectionDefinition ??
+ throw new InvalidOperationException(
+ $"Graph node '{nodeId}' is not a straight section.")).Length;
+ }
+
+ private static IReadOnlyList BuildOutcomeDiagnostics(
+ AuthoringEvaluationOutcome outcome)
+ {
+ var diagnostics = new List();
+ if (outcome.Candidate is not null)
{
- return;
+ diagnostics.AddRange(outcome.Candidate.GraphDiagnostics.Select(diagnostic =>
+ $"{diagnostic.Code}: {diagnostic.Message}"));
+ }
+
+ diagnostics.AddRange(outcome.Diagnostics.Select(diagnostic =>
+ $"{diagnostic.Code}: {diagnostic.Message}"));
+ if (outcome.Fault is not null)
+ {
+ diagnostics.Add($"{outcome.Fault.Phase}: {outcome.Fault.Message}");
+ }
+
+ return diagnostics.Count == 0
+ ? new[] { "The provisional straight length is invalid." }
+ : diagnostics.Distinct(StringComparer.Ordinal).ToArray();
+ }
+
+ private void ThrowIfInteractiveEditActive(string message)
+ {
+ if (IsInteractiveEditActive)
+ {
+ throw new InvalidOperationException(message);
}
+ }
+
+ private void ThrowIfDisposed()
+ {
+ ObjectDisposedException.ThrowIf(disposed, this);
+ }
- if (GraphNodes.Any(node =>
- string.Equals(node.NodeId, selection.NodeId, StringComparison.Ordinal)))
+ public void Dispose()
+ {
+ if (disposed)
{
return;
}
- Select(EditorSelection.Track);
+ CancelStraightLengthEdit();
+ Documents.ActiveDocumentChanged -= OnActiveDocumentChanged;
+ Selection.SelectionChanged -= OnSelectionChanged;
+ UndoRedo.StateChanged -= OnUndoRedoStateChanged;
+ if (observedDocument is not null)
+ {
+ observedDocument.Changed -= OnDocumentChanged;
+ }
+
+ evaluationCoordinator?.Dispose();
+ evaluationCoordinator = null;
+ authoringSession = null;
+ observedDocument = null;
+ disposed = true;
}
private static IReadOnlyList BuildGraphNodes(
@@ -704,9 +1191,12 @@ private int FindGraphNodeIndex(string nodeId)
return -1;
}
- private static IReadOnlyList BuildOutliner(TrackEditorDocument document)
+ private static IReadOnlyList BuildOutliner(
+ TrackEditorDocument document,
+ PreparedTrackGraphState state)
{
- IReadOnlyList route = document.GraphCompileResult!.OrderedNodes;
+ IReadOnlyList route =
+ state.GraphCompileResult?.OrderedNodes ?? Array.Empty();
var sectionNodes = new List(route.Count);
for (int sectionIndex = 0; sectionIndex < route.Count; sectionIndex++)
{
@@ -731,7 +1221,7 @@ private static IReadOnlyList BuildOutliner(TrackEditorDocume
var bankingNodes = new List();
IReadOnlyList bankingKeys =
- document.Graph!.Banking?.Keys ?? Array.Empty();
+ state.Graph.Banking?.Keys ?? Array.Empty();
for (int keyIndex = 0; keyIndex < bankingKeys.Count; keyIndex++)
{
BankingProfileKey key = bankingKeys[keyIndex];
@@ -740,7 +1230,7 @@ private static IReadOnlyList BuildOutliner(TrackEditorDocume
EditorSelection.BankingKey(keyIndex)));
}
- HeartlineOffset? heartline = document.AncillaryState?.HeartlineOffset;
+ HeartlineOffset? heartline = state.AncillaryState.HeartlineOffset;
return new[]
{
new EditorOutlinerNode(
diff --git a/Quantum.Tests/Editor/M167LiveEditWorkspaceTests.cs b/Quantum.Tests/Editor/M167LiveEditWorkspaceTests.cs
new file mode 100644
index 0000000..2ed6d1e
--- /dev/null
+++ b/Quantum.Tests/Editor/M167LiveEditWorkspaceTests.cs
@@ -0,0 +1,234 @@
+using Quantum.Application.Authoring;
+using Quantum.Editor.Avalonia.Services;
+using Quantum.Editor.Avalonia.Services.Authoring;
+using Quantum.Editor.Avalonia.Services.Documents;
+using Quantum.IO.TrackLayout.V2;
+using Quantum.Track.Authoring;
+
+namespace Quantum.Tests.Editor;
+
+public sealed class M167LiveEditWorkspaceTests
+{
+ [Fact]
+ public void ActiveDocumentOwnsOneStableSessionAndCoordinator()
+ {
+ using var workspace = CreateWorkspace(out _);
+ TrackAuthoringSession session = Assert.IsType(
+ workspace.AuthoringSession);
+ TrackAuthoringEvaluationCoordinator coordinator =
+ Assert.IsType(workspace.EvaluationCoordinator);
+
+ workspace.SetStatus("Refresh without replacing the document.");
+
+ Assert.Same(session, workspace.AuthoringSession);
+ Assert.Same(coordinator, workspace.EvaluationCoordinator);
+ Assert.Equal(
+ AuthoringEvaluationExecutionMode.SerializedBackground,
+ coordinator.Options.ExecutionMode);
+ }
+
+ [Fact]
+ public void DocumentReplacementCancelsTransactionAndDisposesCoordinator()
+ {
+ using var workspace = CreateWorkspace(out _);
+ TrackAuthoringSession oldSession = workspace.AuthoringSession!;
+ TrackAuthoringEvaluationCoordinator oldCoordinator = workspace.EvaluationCoordinator!;
+ Assert.True(workspace.BeginStraightLengthEdit("launch"));
+ TransactionRevision revision = workspace.StraightLengthEdit!.TransactionRevision;
+
+ workspace.NewDocument();
+
+ Assert.Null(oldSession.ActiveTransaction);
+ Assert.False(workspace.IsInteractiveEditActive);
+ Assert.NotSame(oldSession, workspace.AuthoringSession);
+ Assert.NotSame(oldCoordinator, workspace.EvaluationCoordinator);
+ Assert.Throws(() => oldCoordinator.SubmitProvisionalEdit(
+ revision,
+ TrackAuthoringCandidateOperation.Replace(
+ "launch",
+ new StraightSectionDefinition("launch", 32.0))));
+ }
+
+ [Fact]
+ public async Task AcceptedPresentedStateProjectsWithoutChangingCommittedDocument()
+ {
+ using var workspace = CreateWorkspace(out TrackEditorDocument document);
+ TrackAuthoringGraph committedGraph = document.Graph!;
+ TrackAuthoringCompilation committedCompilation = document.Compilation!;
+ string committedJson = document.CapturePackageJson();
+ bool dirty = document.IsDirty;
+ Assert.True(workspace.BeginStraightLengthEdit("launch"));
+
+ AuthoringEvaluationOutcome outcome = await workspace
+ .SubmitStraightLengthEdit(36.0)
+ .Completion;
+ Assert.True(workspace.PublishStraightLengthOutcome(outcome));
+
+ Assert.Equal(AuthoringEvaluationOutcomeStatus.Accepted, outcome.Status);
+ Assert.Same(committedGraph, document.Graph);
+ Assert.Same(committedCompilation, document.Compilation);
+ Assert.Equal(committedJson, document.CapturePackageJson());
+ Assert.Equal(dirty, document.IsDirty);
+ Assert.Equal(36.0, workspace.EngineeringSnapshot!.TotalLength, 9);
+ Assert.Same(outcome.Candidate!.PreparedState, workspace.PresentedState);
+ Assert.False(workspace.UndoRedo.CanUndo);
+ }
+
+ [Fact]
+ public async Task SuccessfulCommitAdoptsExactCandidateWithoutCompilationAndCreatesOneUndoEntry()
+ {
+ using var workspace = CreateWorkspace(out TrackEditorDocument document);
+ PreparedTrackGraphState before = workspace.AuthoringSession!.CommittedState.PreparedState;
+ Assert.True(workspace.BeginStraightLengthEdit("launch"));
+ AuthoringEvaluationOutcome outcome = await workspace
+ .SubmitStraightLengthEdit(36.0)
+ .Completion;
+ workspace.PublishStraightLengthOutcome(outcome);
+ PreparedTrackGraphState accepted = outcome.Candidate!.PreparedState!;
+ using TrackAuthoringPipelineMeasurement measurement =
+ TrackAuthoringPipelineMeasurement.Begin();
+
+ AuthoringScheduledCommitResult result =
+ (await workspace.CommitStraightLengthEditAsync())!;
+
+ Assert.True(result.Succeeded);
+ Assert.True(result.CommitResult!.Changed);
+ Assert.Equal(0, measurement.GraphCompilerInvocationCount);
+ Assert.Same(accepted, workspace.AuthoringSession!.CommittedState.PreparedState);
+ Assert.Same(accepted.Graph, document.Graph);
+ Assert.Same(accepted.GraphCompileResult, document.GraphCompileResult);
+ Assert.True(workspace.UndoRedo.CanUndo);
+ Assert.False(workspace.UndoRedo.CanRedo);
+
+ Assert.True(workspace.UndoLast());
+ Assert.Same(before.Graph, document.Graph);
+ Assert.True(workspace.RedoLast());
+ Assert.Same(accepted.Graph, document.Graph);
+ Assert.Same(accepted.GraphCompileResult, document.GraphCompileResult);
+ }
+
+ [Fact]
+ public async Task InvalidLatestRevisionRestoresCommittedStateAndCreatesNoHistory()
+ {
+ using var workspace = CreateWorkspace(out TrackEditorDocument document);
+ TrackAuthoringGraph committedGraph = document.Graph!;
+ string committedJson = document.CapturePackageJson();
+ bool dirty = document.IsDirty;
+ Assert.True(workspace.BeginStraightLengthEdit("launch"));
+ AuthoringEvaluationOutcome valid = await workspace
+ .SubmitStraightLengthEdit(34.0)
+ .Completion;
+ workspace.PublishStraightLengthOutcome(valid);
+ Assert.Equal(34.0, workspace.ViewportSnapshot.TotalLength, 9);
+
+ AuthoringEvaluationOutcome invalid = await workspace
+ .SubmitStraightLengthEdit(double.NaN)
+ .Completion;
+ Assert.True(workspace.PublishStraightLengthOutcome(invalid));
+ Assert.Equal(StraightLengthEditStatus.Invalid, workspace.StraightLengthEdit!.Status);
+ Assert.Contains("Last valid preview", workspace.StraightLengthEdit.StatusText);
+ Assert.Equal(34.0, workspace.ViewportSnapshot.TotalLength, 9);
+
+ AuthoringScheduledCommitResult result =
+ (await workspace.CommitStraightLengthEditAsync())!;
+
+ Assert.False(result.Succeeded);
+ Assert.Same(committedGraph, document.Graph);
+ Assert.Equal(committedJson, document.CapturePackageJson());
+ Assert.Equal(dirty, document.IsDirty);
+ Assert.Equal(30.0, workspace.ViewportSnapshot.TotalLength, 9);
+ Assert.False(workspace.UndoRedo.CanUndo);
+ Assert.Null(workspace.StraightLengthEdit);
+ }
+
+ [Fact]
+ public async Task CancelRestoresCommittedPresentationAndCreatesNoHistory()
+ {
+ using var workspace = CreateWorkspace(out TrackEditorDocument document);
+ TrackAuthoringGraph committedGraph = document.Graph!;
+ Assert.True(workspace.BeginStraightLengthEdit("launch"));
+ AuthoringEvaluationOutcome outcome = await workspace
+ .SubmitStraightLengthEdit(36.0)
+ .Completion;
+ workspace.PublishStraightLengthOutcome(outcome);
+ Assert.Equal(36.0, workspace.ViewportSnapshot.TotalLength, 9);
+
+ Assert.True(workspace.CancelStraightLengthEdit());
+
+ Assert.Same(committedGraph, document.Graph);
+ Assert.Equal(30.0, workspace.ViewportSnapshot.TotalLength, 9);
+ Assert.False(workspace.UndoRedo.CanUndo);
+ Assert.Null(workspace.AuthoringSession!.ActiveTransaction);
+ }
+
+ [Fact]
+ public async Task NoOpReleaseCreatesNoHistory()
+ {
+ using var workspace = CreateWorkspace(out TrackEditorDocument document);
+ Assert.True(workspace.BeginStraightLengthEdit("launch"));
+ AuthoringEvaluationOutcome outcome = await workspace
+ .SubmitStraightLengthEdit(30.0)
+ .Completion;
+ workspace.PublishStraightLengthOutcome(outcome);
+
+ AuthoringScheduledCommitResult result =
+ (await workspace.CommitStraightLengthEditAsync())!;
+
+ Assert.True(result.Succeeded);
+ Assert.False(result.CommitResult!.Changed);
+ Assert.False(workspace.UndoRedo.CanUndo);
+ Assert.Equal(30.0, StraightLength(document.Graph!, "launch"), 9);
+ }
+
+ [Fact]
+ public async Task SaveIsDisabledAndCannotPersistProvisionalContent()
+ {
+ using var workspace = CreateWorkspace(out TrackEditorDocument document);
+ string committedJson = document.CapturePackageJson();
+ Assert.True(workspace.BeginStraightLengthEdit("launch"));
+ AuthoringEvaluationOutcome outcome = await workspace
+ .SubmitStraightLengthEdit(36.0)
+ .Completion;
+ workspace.PublishStraightLengthOutcome(outcome);
+
+ Assert.False(workspace.Commands.CanExecute(
+ Quantum.Editor.Avalonia.Services.Commands.EditorCommandIds.SaveDocument));
+ InvalidOperationException exception = Assert.Throws(
+ () => workspace.SaveDocument(Path.GetTempFileName()));
+ Assert.Contains("disabled", exception.Message, StringComparison.OrdinalIgnoreCase);
+ Assert.Equal(committedJson, document.CapturePackageJson());
+ }
+
+ private static EditorWorkspace CreateWorkspace(out TrackEditorDocument document)
+ {
+ var workspace = new EditorWorkspace();
+ document = TrackEditorDocument.Create(
+ new TrackLayoutPackageV2Dto
+ {
+ Metadata = new TrackLayoutMetadataV2Dto
+ {
+ Units = "meters",
+ SourceName = "M167.4 self-authored straight fixture",
+ LayoutId = "m167-4-straight"
+ },
+ Sections = new[]
+ {
+ new TrackLayoutSectionV2Dto
+ {
+ Kind = TrackLayoutPackageV2Vocabulary.StraightSectionKind,
+ Id = "launch",
+ Length = 30.0
+ }
+ }
+ },
+ "M167.4 straight fixture");
+ workspace.Documents.SetActiveDocument(document);
+ return workspace;
+ }
+
+ private static double StraightLength(TrackAuthoringGraph graph, string nodeId)
+ {
+ return Assert.IsType(
+ graph.Nodes.Single(node => node.Id == nodeId).Section).Length;
+ }
+}
From c52623842154fa4b0084ab7a34ce3a4abe803d08 Mon Sep 17 00:00:00 2001
From: Coasterpete <83011124+Coasterpete@users.noreply.github.com>
Date: Wed, 22 Jul 2026 17:11:11 -0400
Subject: [PATCH 09/11] feat(editor): add straight length scrub interaction
---
.../TrackAuthoringEvaluationCoordinator.cs | 19 +-
.../Controls/DiagnosticsPaneControl.axaml.cs | 17 +-
.../Controls/InspectorPaneControl.axaml.cs | 237 ++++++++-
.../Controls/RoutePaneControl.axaml.cs | 27 +-
.../Controls/StraightLengthScrubberControl.cs | 187 +++++++
.../Controls/TrackViewportControl.cs | 47 +-
.../Controls/ViewportPaneControl.axaml.cs | 8 +
Quantum.Editor.Avalonia/MainWindow.axaml.cs | 25 +-
...ngEvaluationCoordinatorIntegrationTests.cs | 35 ++
.../Editor/M167InspectorHeadlessTests.cs | 493 ++++++++++++++++++
.../Editor/M167LiveEditWorkspaceTests.cs | 112 ++++
Quantum.Tests/Quantum.Tests.csproj | 1 +
12 files changed, 1190 insertions(+), 18 deletions(-)
create mode 100644 Quantum.Editor.Avalonia/Controls/StraightLengthScrubberControl.cs
create mode 100644 Quantum.Tests/Editor/M167InspectorHeadlessTests.cs
diff --git a/Quantum.Application/Authoring/TrackAuthoringEvaluationCoordinator.cs b/Quantum.Application/Authoring/TrackAuthoringEvaluationCoordinator.cs
index 576df04..f3a7729 100644
--- a/Quantum.Application/Authoring/TrackAuthoringEvaluationCoordinator.cs
+++ b/Quantum.Application/Authoring/TrackAuthoringEvaluationCoordinator.cs
@@ -465,11 +465,24 @@ private async Task WorkerLoopAsync()
if (delay > TimeSpan.Zero)
{
- Task delayTask = clock.Delay(delay, shutdown.Token);
- Task signalTask = workSignal.WaitAsync(shutdown.Token);
+ using (var waitCancellation =
+ CancellationTokenSource.CreateLinkedTokenSource(shutdown.Token))
try
{
- await Task.WhenAny(delayTask, signalTask).ConfigureAwait(false);
+ Task delayTask = clock.Delay(delay, waitCancellation.Token);
+ Task signalTask = workSignal.WaitAsync(waitCancellation.Token);
+ Task completed = await Task.WhenAny(
+ delayTask,
+ signalTask).ConfigureAwait(false);
+ waitCancellation.Cancel();
+ try
+ {
+ await completed.ConfigureAwait(false);
+ }
+ catch (OperationCanceledException) when (
+ !shutdown.IsCancellationRequested)
+ {
+ }
}
catch (OperationCanceledException)
{
diff --git a/Quantum.Editor.Avalonia/Controls/DiagnosticsPaneControl.axaml.cs b/Quantum.Editor.Avalonia/Controls/DiagnosticsPaneControl.axaml.cs
index 18098d2..c9b6930 100644
--- a/Quantum.Editor.Avalonia/Controls/DiagnosticsPaneControl.axaml.cs
+++ b/Quantum.Editor.Avalonia/Controls/DiagnosticsPaneControl.axaml.cs
@@ -7,6 +7,7 @@ namespace Quantum.Editor.Avalonia.Controls;
public partial class DiagnosticsPaneControl : UserControl
{
private TrackViewportSnapshot snapshot = TrackViewportSnapshot.Empty;
+ private IReadOnlyList liveAuthoringDiagnostics = Array.Empty();
public DiagnosticsPaneControl()
{
@@ -24,9 +25,21 @@ public TrackViewportSnapshot Snapshot
}
}
+ public IReadOnlyList LiveAuthoringDiagnostics
+ {
+ get => liveAuthoringDiagnostics;
+ set
+ {
+ liveAuthoringDiagnostics = value ?? Array.Empty();
+ UpdatePresentation();
+ }
+ }
+
private void UpdatePresentation()
{
var lines = new List(snapshot.Diagnostics);
+ lines.AddRange(liveAuthoringDiagnostics.Select(diagnostic =>
+ "Live edit: " + diagnostic));
TrackFrameContinuityReport? continuity = snapshot.ContinuityReport;
if (continuity != null)
{
@@ -39,7 +52,9 @@ private void UpdatePresentation()
}
DiagnosticsList.ItemsSource = lines;
- DiagnosticSummaryText.Text = continuity is null
+ DiagnosticSummaryText.Text = liveAuthoringDiagnostics.Count != 0
+ ? $"{liveAuthoringDiagnostics.Count} live authoring diagnostic(s)"
+ : continuity is null
? "No compiled diagnostics"
: $"{continuity.IntervalCount} intervals | {continuity.Issues.Count} issues";
}
diff --git a/Quantum.Editor.Avalonia/Controls/InspectorPaneControl.axaml.cs b/Quantum.Editor.Avalonia/Controls/InspectorPaneControl.axaml.cs
index a4e0e21..fc7b67f 100644
--- a/Quantum.Editor.Avalonia/Controls/InspectorPaneControl.axaml.cs
+++ b/Quantum.Editor.Avalonia/Controls/InspectorPaneControl.axaml.cs
@@ -1,9 +1,13 @@
using System.Globalization;
using Avalonia.Controls;
+using Avalonia.Input;
using Avalonia.Layout;
using Avalonia.Media;
+using Avalonia.Threading;
+using Quantum.Application.Authoring;
using Quantum.Editor.Avalonia.Models;
using Quantum.Editor.Avalonia.Services;
+using Quantum.Editor.Avalonia.Services.Authoring;
using Quantum.Editor.Avalonia.Services.Documents;
using Quantum.Editor.Avalonia.Services.Plots;
using Quantum.Track;
@@ -17,6 +21,11 @@ public partial class InspectorPaneControl : UserControl
private readonly Dictionary numericInspectorFields =
new(StringComparer.Ordinal);
private EditorWorkspace? workspace;
+ private StraightLengthScrubberControl? lengthScrubber;
+ private TextBlock? lengthScrubStatus;
+ private string? activeInspectorNodeId;
+ private double scrubStartLength;
+ private double? lastSubmittedLength;
public InspectorPaneControl()
{
@@ -28,6 +37,12 @@ public InspectorPaneControl()
public void Refresh(EditorWorkspace editorWorkspace)
{
workspace = editorWorkspace ?? throw new ArgumentNullException(nameof(editorWorkspace));
+ if (CanRefreshActiveScrubInPlace(editorWorkspace))
+ {
+ RefreshActiveScrubPresentation();
+ return;
+ }
+
RebuildInspector();
}
@@ -36,6 +51,9 @@ private void RebuildInspector()
EditorWorkspace currentWorkspace = GetWorkspace();
inspectorFields.Clear();
numericInspectorFields.Clear();
+ lengthScrubber = null;
+ lengthScrubStatus = null;
+ activeInspectorNodeId = null;
InspectorFieldsPanel.Children.Clear();
TrackEditorDocument? document = currentWorkspace.ActiveDocument;
@@ -123,7 +141,9 @@ private void BuildGraphNodeInspector(
EditorSelection selection)
{
EditorWorkspace currentWorkspace = GetWorkspace();
- IReadOnlyList route = document.GraphCompileResult!.OrderedNodes;
+ IReadOnlyList route =
+ currentWorkspace.PresentedState?.GraphCompileResult?.OrderedNodes ??
+ document.GraphCompileResult!.OrderedNodes;
TrackAuthoringGraphNode? node = selection.NodeId is null
? null
: route.FirstOrDefault(candidate =>
@@ -150,11 +170,32 @@ section is ConstantCurvatureSectionDefinition ||
AddInspectorField("kind", "Section type", DescribeSectionKind(section), editable: false);
if (supportsParameterEditing)
{
+ Control? lengthAdornment = null;
+ if (section is StraightSectionDefinition)
+ {
+ lengthScrubber = CreateLengthScrubber();
+ lengthAdornment = lengthScrubber;
+ activeInspectorNodeId = node.Id;
+ }
+
AddInspectorNumericField(
"sectionLength",
"Length (m)",
section.Length,
- AuthoringNumericParameterKind.LengthMeters);
+ AuthoringNumericParameterKind.LengthMeters,
+ lengthAdornment);
+ if (lengthScrubber is not null)
+ {
+ lengthScrubStatus = new TextBlock
+ {
+ Foreground = Brush.Parse("#7F94A8"),
+ TextWrapping = TextWrapping.Wrap,
+ FontSize = 11
+ };
+ InspectorFieldsPanel.Children.Add(lengthScrubStatus);
+ RefreshActiveScrubPresentation();
+ }
+
AddInspectorNumericField(
"rollDegrees",
"Section roll (deg)",
@@ -401,11 +442,13 @@ private void AddInspectorNumericField(
string key,
string label,
double value,
- AuthoringNumericParameterKind kind)
+ AuthoringNumericParameterKind kind,
+ Control? adornment = null)
{
var grid = new Grid
{
- ColumnDefinitions = new ColumnDefinitions("118,*"),
+ ColumnDefinitions = new ColumnDefinitions(
+ adornment is null ? "118,*" : "118,*,Auto"),
ColumnSpacing = 8
};
var labelBlock = new TextBlock
@@ -421,10 +464,196 @@ private void AddInspectorNumericField(
Grid.SetColumn(field, 1);
grid.Children.Add(labelBlock);
grid.Children.Add(field);
+ if (adornment is not null)
+ {
+ Grid.SetColumn(adornment, 2);
+ grid.Children.Add(adornment);
+ }
+
InspectorFieldsPanel.Children.Add(grid);
numericInspectorFields[key] = field;
}
+ private StraightLengthScrubberControl CreateLengthScrubber()
+ {
+ var scrubber = new StraightLengthScrubberControl();
+ scrubber.ScrubStarted += OnLengthScrubStarted;
+ scrubber.ScrubDelta += OnLengthScrubDelta;
+ scrubber.CommitRequested += OnLengthScrubCommitRequested;
+ scrubber.CancelRequested += OnLengthScrubCancelRequested;
+ return scrubber;
+ }
+
+ private void OnLengthScrubStarted(object? sender, EventArgs eventArgs)
+ {
+ EditorWorkspace currentWorkspace = GetWorkspace();
+ if (activeInspectorNodeId is null ||
+ !currentWorkspace.BeginStraightLengthEdit(activeInspectorNodeId))
+ {
+ lengthScrubber?.Cancel();
+ return;
+ }
+
+ scrubStartLength = currentWorkspace.StraightLengthEdit!.CommittedLength;
+ lastSubmittedLength = null;
+ SubmitLengthPreview(scrubStartLength);
+ }
+
+ private void OnLengthScrubDelta(
+ object? sender,
+ StraightLengthScrubDeltaEventArgs eventArgs)
+ {
+ EditorWorkspace currentWorkspace = GetWorkspace();
+ currentWorkspace.RecordStraightLengthPointerUpdate();
+ double sensitivity = (eventArgs.Modifiers & KeyModifiers.Shift) != 0
+ ? 0.01
+ : (eventArgs.Modifiers & KeyModifiers.Control) != 0
+ ? 1.0
+ : 0.1;
+ double absoluteLength =
+ scrubStartLength + (eventArgs.TotalHorizontalDelta * sensitivity);
+ SubmitLengthPreview(absoluteLength);
+ }
+
+ private void SubmitLengthPreview(double absoluteLength)
+ {
+ if (lastSubmittedLength.HasValue &&
+ lastSubmittedLength.Value.Equals(absoluteLength))
+ {
+ return;
+ }
+
+ lastSubmittedLength = absoluteLength;
+ try
+ {
+ AuthoringEvaluationSubmission submission =
+ GetWorkspace().SubmitStraightLengthEdit(absoluteLength);
+ _ = ObserveSubmissionAsync(submission);
+ }
+ catch (Exception exception) when (
+ exception is InvalidOperationException ||
+ exception is ObjectDisposedException)
+ {
+ GetWorkspace().SetStatus("Live edit failed: " + exception.Message);
+ lengthScrubber?.Cancel();
+ }
+ }
+
+ private async Task ObserveSubmissionAsync(AuthoringEvaluationSubmission submission)
+ {
+ AuthoringEvaluationOutcome outcome = await submission.Completion.ConfigureAwait(false);
+ await Dispatcher.UIThread.InvokeAsync(() =>
+ {
+ EditorWorkspace? currentWorkspace = workspace;
+ if (currentWorkspace is not null &&
+ currentWorkspace.PublishStraightLengthOutcome(outcome))
+ {
+ RefreshActiveScrubPresentation();
+ }
+ });
+ }
+
+ private async void OnLengthScrubCommitRequested(object? sender, EventArgs eventArgs)
+ {
+ try
+ {
+ await GetWorkspace().CommitStraightLengthEditAsync();
+ }
+ catch (OperationCanceledException)
+ {
+ GetWorkspace().CancelStraightLengthEdit();
+ }
+ catch (Exception exception) when (
+ exception is InvalidOperationException ||
+ exception is ObjectDisposedException)
+ {
+ GetWorkspace().CancelStraightLengthEdit();
+ GetWorkspace().SetStatus("Live edit commit failed: " + exception.Message);
+ }
+ }
+
+ private void OnLengthScrubCancelRequested(object? sender, EventArgs eventArgs)
+ {
+ GetWorkspace().CancelStraightLengthEdit();
+ }
+
+ private bool CanRefreshActiveScrubInPlace(EditorWorkspace editorWorkspace)
+ {
+ StraightLengthEditState? edit = editorWorkspace.StraightLengthEdit;
+ return lengthScrubber?.IsScrubbing == true &&
+ edit is not null &&
+ string.Equals(edit.NodeId, activeInspectorNodeId, StringComparison.Ordinal) &&
+ editorWorkspace.CurrentSelection?.Kind == EditorSelectionKind.Section &&
+ string.Equals(
+ editorWorkspace.CurrentSelection.NodeId,
+ edit.NodeId,
+ StringComparison.Ordinal);
+ }
+
+ private void RefreshActiveScrubPresentation()
+ {
+ if (lengthScrubber is null ||
+ !numericInspectorFields.TryGetValue("sectionLength", out NumericUpDown? field))
+ {
+ return;
+ }
+
+ StraightLengthEditState? edit = workspace?.StraightLengthEdit;
+ if (edit is null)
+ {
+ lengthScrubber.IsInvalid = false;
+ if (lengthScrubStatus is not null)
+ {
+ lengthScrubStatus.Text =
+ "Drag ↔ for live preview. Shift: fine, Ctrl: coarse.";
+ }
+
+ return;
+ }
+
+ string rawText = edit.RawLength.ToString("0.######", CultureInfo.InvariantCulture);
+ field.Text = rawText;
+ if (double.IsFinite(edit.RawLength))
+ {
+ try
+ {
+ field.Value = (decimal)edit.RawLength;
+ field.Text = rawText;
+ }
+ catch (OverflowException)
+ {
+ field.Value = null;
+ }
+ }
+ else
+ {
+ field.Value = null;
+ }
+
+ field.BorderBrush = Brush.Parse(edit.IsInvalid ? "#FF6B6B" : "#45657D");
+ lengthScrubber.IsInvalid = edit.IsInvalid;
+ if (lengthScrubStatus is not null)
+ {
+ string accepted = edit.AcceptedPreviewLength.HasValue
+ ? edit.AcceptedPreviewLength.Value.ToString(
+ "0.######",
+ CultureInfo.InvariantCulture) + " m"
+ : "none";
+ lengthScrubStatus.Text =
+ $"{edit.StatusText} | Committed {edit.CommittedLength:0.######} m" +
+ $" | Accepted {accepted}" +
+ (edit.Diagnostics.Count == 0
+ ? string.Empty
+ : Environment.NewLine + string.Join(" ", edit.Diagnostics));
+ lengthScrubStatus.Foreground = Brush.Parse(
+ edit.IsInvalid ? "#FF8F8F" : "#7F94A8");
+ }
+ }
+
+ internal StraightLengthScrubberControl? LengthScrubber => lengthScrubber;
+
+ internal string? LengthScrubStatusText => lengthScrubStatus?.Text;
+
private void AddInspectorNote(string text)
{
InspectorFieldsPanel.Children.Add(new TextBlock
diff --git a/Quantum.Editor.Avalonia/Controls/RoutePaneControl.axaml.cs b/Quantum.Editor.Avalonia/Controls/RoutePaneControl.axaml.cs
index d31d091..3e31508 100644
--- a/Quantum.Editor.Avalonia/Controls/RoutePaneControl.axaml.cs
+++ b/Quantum.Editor.Avalonia/Controls/RoutePaneControl.axaml.cs
@@ -14,6 +14,7 @@ public partial class RoutePaneControl : UserControl
private readonly Dictionary graphNodeButtons = new();
private EditorSelection? selection;
private int highlightedSectionIndex = -1;
+ private bool sourceEditingEnabled = true;
public RoutePaneControl()
{
@@ -78,6 +79,21 @@ public int HighlightedSectionIndex
}
}
+ public bool SourceEditingEnabled
+ {
+ get => sourceEditingEnabled;
+ set
+ {
+ if (sourceEditingEnabled == value)
+ {
+ return;
+ }
+
+ sourceEditingEnabled = value;
+ UpdateNodeAppearance();
+ }
+ }
+
private void RebuildRoute()
{
GraphNodesPanel.Children.Clear();
@@ -184,15 +200,18 @@ private void UpdateNodeAppearance()
highlighted ? "#3A3421" : selected ? "#203B50" : "#18232E");
button.BorderBrush = Brush.Parse(
highlighted ? "#F4D35E" : selected ? "#59B5E8" : "#34495C");
+ button.IsEnabled = sourceEditingEnabled;
}
EditorGraphNode? selectedNode = SelectedNode();
bool hasSelection = selectedNode != null;
- InsertBeforeButton.IsEnabled = hasSelection;
- InsertAfterButton.IsEnabled = hasSelection;
- DeleteSectionButton.IsEnabled = hasSelection;
- MoveUpButton.IsEnabled = selectedNode?.RouteIndex > 0;
+ AddSectionButton.IsEnabled = sourceEditingEnabled;
+ InsertBeforeButton.IsEnabled = sourceEditingEnabled && hasSelection;
+ InsertAfterButton.IsEnabled = sourceEditingEnabled && hasSelection;
+ DeleteSectionButton.IsEnabled = sourceEditingEnabled && hasSelection;
+ MoveUpButton.IsEnabled = sourceEditingEnabled && selectedNode?.RouteIndex > 0;
MoveDownButton.IsEnabled =
+ sourceEditingEnabled &&
selectedNode != null && selectedNode.RouteIndex < graphNodes.Count - 1;
}
diff --git a/Quantum.Editor.Avalonia/Controls/StraightLengthScrubberControl.cs b/Quantum.Editor.Avalonia/Controls/StraightLengthScrubberControl.cs
new file mode 100644
index 0000000..d4a7b6c
--- /dev/null
+++ b/Quantum.Editor.Avalonia/Controls/StraightLengthScrubberControl.cs
@@ -0,0 +1,187 @@
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Input;
+using Avalonia.Media;
+using Avalonia.VisualTree;
+
+namespace Quantum.Editor.Avalonia.Controls;
+
+internal sealed class StraightLengthScrubDeltaEventArgs : EventArgs
+{
+ internal StraightLengthScrubDeltaEventArgs(double totalHorizontalDelta, KeyModifiers modifiers)
+ {
+ TotalHorizontalDelta = totalHorizontalDelta;
+ Modifiers = modifiers;
+ }
+
+ internal double TotalHorizontalDelta { get; }
+
+ internal KeyModifiers Modifiers { get; }
+}
+
+///
+/// Avalonia-only pointer-capture surface for the first live authoring gesture.
+/// It reports total displacement; absolute-value derivation remains with the Inspector.
+///
+internal sealed class StraightLengthScrubberControl : Border
+{
+ private Point startPosition;
+ private IPointer? capturedPointer;
+ private bool releasingCapture;
+
+ internal StraightLengthScrubberControl()
+ {
+ Focusable = true;
+ Width = 34;
+ MinHeight = 28;
+ Padding = new Thickness(7, 3);
+ CornerRadius = new CornerRadius(4);
+ Background = Brush.Parse("#203140");
+ BorderBrush = Brush.Parse("#45657D");
+ BorderThickness = new Thickness(1);
+ Cursor = new Cursor(StandardCursorType.SizeWestEast);
+ ToolTip.SetTip(this, "Drag horizontally to preview Length. Shift: fine, Ctrl: coarse.");
+ Child = new TextBlock
+ {
+ Text = "↔",
+ HorizontalAlignment = global::Avalonia.Layout.HorizontalAlignment.Center,
+ VerticalAlignment = global::Avalonia.Layout.VerticalAlignment.Center,
+ Foreground = Brush.Parse("#B9D8ED"),
+ FontWeight = FontWeight.Bold
+ };
+ }
+
+ internal event EventHandler? ScrubStarted;
+
+ internal event EventHandler? ScrubDelta;
+
+ internal event EventHandler? CommitRequested;
+
+ internal event EventHandler? CancelRequested;
+
+ internal bool IsScrubbing { get; private set; }
+
+ internal bool IsInvalid
+ {
+ set
+ {
+ BorderBrush = Brush.Parse(value ? "#FF6B6B" : "#45657D");
+ Background = Brush.Parse(value ? "#48252A" : "#203140");
+ }
+ }
+
+ protected override void OnPointerPressed(PointerPressedEventArgs eventArgs)
+ {
+ base.OnPointerPressed(eventArgs);
+ PointerPoint point = eventArgs.GetCurrentPoint(this);
+ if (IsScrubbing || !point.Properties.IsLeftButtonPressed)
+ {
+ return;
+ }
+
+ IsScrubbing = true;
+ startPosition = point.Position;
+ capturedPointer = eventArgs.Pointer;
+ Focus();
+ eventArgs.Pointer.Capture(this);
+ ScrubStarted?.Invoke(this, EventArgs.Empty);
+ eventArgs.Handled = true;
+ }
+
+ protected override void OnPointerMoved(PointerEventArgs eventArgs)
+ {
+ base.OnPointerMoved(eventArgs);
+ if (!IsScrubbing)
+ {
+ return;
+ }
+
+ Point position = eventArgs.GetPosition(this);
+ ScrubDelta?.Invoke(
+ this,
+ new StraightLengthScrubDeltaEventArgs(
+ position.X - startPosition.X,
+ eventArgs.KeyModifiers));
+ eventArgs.Handled = true;
+ }
+
+ protected override void OnPointerReleased(PointerReleasedEventArgs eventArgs)
+ {
+ base.OnPointerReleased(eventArgs);
+ if (!IsScrubbing || eventArgs.InitialPressMouseButton != MouseButton.Left)
+ {
+ return;
+ }
+
+ EndCapture();
+ CommitRequested?.Invoke(this, EventArgs.Empty);
+ eventArgs.Handled = true;
+ }
+
+ protected override void OnKeyDown(KeyEventArgs eventArgs)
+ {
+ base.OnKeyDown(eventArgs);
+ if (IsScrubbing && eventArgs.Key == Key.Escape)
+ {
+ Cancel();
+ eventArgs.Handled = true;
+ }
+ }
+
+ protected override void OnPointerCaptureLost(PointerCaptureLostEventArgs eventArgs)
+ {
+ base.OnPointerCaptureLost(eventArgs);
+ if (IsScrubbing && !releasingCapture)
+ {
+ IsScrubbing = false;
+ capturedPointer = null;
+ CancelRequested?.Invoke(this, EventArgs.Empty);
+ }
+ }
+
+ protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs eventArgs)
+ {
+ if (IsScrubbing)
+ {
+ Cancel();
+ }
+
+ base.OnDetachedFromVisualTree(eventArgs);
+ }
+
+ internal void Cancel()
+ {
+ if (!IsScrubbing)
+ {
+ return;
+ }
+
+ EndCapture();
+ CancelRequested?.Invoke(this, EventArgs.Empty);
+ }
+
+ internal void ReleasePointerCapture()
+ {
+ capturedPointer?.Capture(null);
+ }
+
+ private void EndCapture()
+ {
+ if (!IsScrubbing)
+ {
+ return;
+ }
+
+ releasingCapture = true;
+ try
+ {
+ IsScrubbing = false;
+ capturedPointer?.Capture(null);
+ capturedPointer = null;
+ }
+ finally
+ {
+ releasingCapture = false;
+ }
+ }
+}
diff --git a/Quantum.Editor.Avalonia/Controls/TrackViewportControl.cs b/Quantum.Editor.Avalonia/Controls/TrackViewportControl.cs
index d9825f0..4852d48 100644
--- a/Quantum.Editor.Avalonia/Controls/TrackViewportControl.cs
+++ b/Quantum.Editor.Avalonia/Controls/TrackViewportControl.cs
@@ -8,6 +8,13 @@
namespace Quantum.Editor.Avalonia.Controls;
+internal readonly record struct TrackViewportCameraState(
+ TrackViewportProjection Projection,
+ Point WorldCenter,
+ Vector Pan,
+ double Scale,
+ bool FitPending);
+
public sealed class TrackViewportControl : Control, IViewportSurface
{
private static readonly Color[] SectionColors =
@@ -29,6 +36,7 @@ public sealed class TrackViewportControl : Control, IViewportSurface
private TrackViewportProjection projection = TrackViewportProjection.Isometric;
private bool showFrames = true;
private bool fitPending = true;
+ private bool fitOnNextNonEmptySnapshot = true;
private bool panning;
private Point lastPointerPosition;
private Point worldCenter;
@@ -64,7 +72,11 @@ public TrackViewportSnapshot Snapshot
stationCursorSampleIndex = -1;
}
- fitPending = true;
+ if (snapshot.Samples.Count != 0 && fitOnNextNonEmptySnapshot)
+ {
+ fitPending = true;
+ fitOnNextNonEmptySnapshot = false;
+ }
InvalidateVisual();
}
}
@@ -133,6 +145,7 @@ public TrackViewportProjection Projection
projection = value;
fitPending = true;
+ fitOnNextNonEmptySnapshot = false;
InvalidateVisual();
}
}
@@ -150,6 +163,38 @@ public bool ShowFrames
public void FitToTrack()
{
fitPending = true;
+ fitOnNextNonEmptySnapshot = false;
+ InvalidateVisual();
+ }
+
+ internal void BeginDocumentPresentation()
+ {
+ fitOnNextNonEmptySnapshot = true;
+ }
+
+ internal TrackViewportCameraState CaptureCameraState() =>
+ new TrackViewportCameraState(
+ projection,
+ worldCenter,
+ pan,
+ scale,
+ fitPending);
+
+ internal void SetCameraState(
+ Point replacementWorldCenter,
+ Vector replacementPan,
+ double replacementScale)
+ {
+ if (!double.IsFinite(replacementScale) || replacementScale <= 0.0)
+ {
+ throw new ArgumentOutOfRangeException(nameof(replacementScale));
+ }
+
+ worldCenter = replacementWorldCenter;
+ pan = replacementPan;
+ scale = replacementScale;
+ fitPending = false;
+ fitOnNextNonEmptySnapshot = false;
InvalidateVisual();
}
diff --git a/Quantum.Editor.Avalonia/Controls/ViewportPaneControl.axaml.cs b/Quantum.Editor.Avalonia/Controls/ViewportPaneControl.axaml.cs
index e11edf2..555bc95 100644
--- a/Quantum.Editor.Avalonia/Controls/ViewportPaneControl.axaml.cs
+++ b/Quantum.Editor.Avalonia/Controls/ViewportPaneControl.axaml.cs
@@ -72,6 +72,14 @@ public void FitToTrack()
ViewportControl.FitToTrack();
}
+ public void BeginDocumentPresentation()
+ {
+ ViewportControl.BeginDocumentPresentation();
+ }
+
+ internal TrackViewportCameraState CaptureCameraState() =>
+ ViewportControl.CaptureCameraState();
+
private void UpdatePresentation()
{
ViewportStatsText.Text = snapshot.Samples.Count == 0
diff --git a/Quantum.Editor.Avalonia/MainWindow.axaml.cs b/Quantum.Editor.Avalonia/MainWindow.axaml.cs
index 6bac08d..189b29b 100644
--- a/Quantum.Editor.Avalonia/MainWindow.axaml.cs
+++ b/Quantum.Editor.Avalonia/MainWindow.axaml.cs
@@ -43,6 +43,7 @@ public partial class MainWindow : Window
private readonly TrainConfigurationPaneControl TrainConfigurationPane;
private readonly TrainPreviewPaneControl TrainPreviewPane;
private readonly TrainSummaryPaneControl TrainSummaryPane;
+ private TrackEditorDocument? presentedViewportDocument;
public MainWindow()
: this(
@@ -324,7 +325,12 @@ private void UpdateWorkspaceView()
RoutePane.DocumentPath = document?.FilePath ?? "Unsaved Track Layout Package V2";
RoutePane.GraphNodes = workspace.GraphNodes;
RoutePane.Selection = selection;
- bool canSaveTrack = document?.CanSave == true;
+ bool interactionActive = workspace.IsInteractiveEditActive;
+ bool canSaveTrack = document?.CanSave == true && !interactionActive;
+ NewButton.IsEnabled = !interactionActive;
+ OpenButton.IsEnabled = !interactionActive;
+ NewTrackMenuItem.IsEnabled = !interactionActive;
+ OpenTrackMenuItem.IsEnabled = !interactionActive;
SaveButton.IsEnabled = canSaveTrack;
SaveTrackMenuItem.IsEnabled = canSaveTrack;
SaveTrackAsMenuItem.IsEnabled = canSaveTrack;
@@ -346,22 +352,30 @@ private void UpdateWorkspaceView()
SelectionText.Text = $"{trainConsistSession.CurrentDefinition.CarCount} car consist";
}
- UndoButton.IsEnabled = workspace.UndoRedo.CanUndo;
- RedoButton.IsEnabled = workspace.UndoRedo.CanRedo;
+ UndoButton.IsEnabled = !interactionActive && workspace.UndoRedo.CanUndo;
+ RedoButton.IsEnabled = !interactionActive && workspace.UndoRedo.CanRedo;
UndoButton.Content = workspace.UndoRedo.UndoDescription is null
? "Undo"
: "Undo " + workspace.UndoRedo.UndoDescription;
RedoButton.Content = workspace.UndoRedo.RedoDescription is null
? "Redo"
: "Redo " + workspace.UndoRedo.RedoDescription;
- UndoMenuItem.IsEnabled = workspace.UndoRedo.CanUndo;
- RedoMenuItem.IsEnabled = workspace.UndoRedo.CanRedo;
+ UndoMenuItem.IsEnabled = !interactionActive && workspace.UndoRedo.CanUndo;
+ RedoMenuItem.IsEnabled = !interactionActive && workspace.UndoRedo.CanRedo;
+ RoutePane.SourceEditingEnabled = !interactionActive;
+ if (!ReferenceEquals(presentedViewportDocument, document))
+ {
+ presentedViewportDocument = document;
+ ViewportPane.BeginDocumentPresentation();
+ }
ViewportPane.Snapshot = snapshot;
ViewportPane.Selection = selection;
MathPlotsPane.Snapshot = workspace.EngineeringSnapshot;
InspectorPane.Refresh(workspace);
DiagnosticsPane.Snapshot = snapshot;
+ DiagnosticsPane.LiveAuthoringDiagnostics =
+ workspace.StraightLengthEdit?.Diagnostics ?? Array.Empty();
RefreshTrainWorkspaceView();
UpdateStationCursorView();
UpdateSectionHighlightView();
@@ -545,6 +559,7 @@ private void OnExitClick(object? sender, RoutedEventArgs eventArgs)
private void OnWindowClosing(object? sender, WindowClosingEventArgs eventArgs)
{
docking.TrySaveLayout();
+ workspace.Dispose();
}
private void OnProjectionChanged(object? sender, SelectionChangedEventArgs eventArgs)
diff --git a/Quantum.Tests/Application/TrackAuthoringEvaluationCoordinatorIntegrationTests.cs b/Quantum.Tests/Application/TrackAuthoringEvaluationCoordinatorIntegrationTests.cs
index e16003a..8f5365a 100644
--- a/Quantum.Tests/Application/TrackAuthoringEvaluationCoordinatorIntegrationTests.cs
+++ b/Quantum.Tests/Application/TrackAuthoringEvaluationCoordinatorIntegrationTests.cs
@@ -164,6 +164,41 @@ public async Task RepeatedBackgroundEvaluationIsDeterministic()
Assert.Equal(2, coordinator.CaptureSnapshot().CompilerInvocationCount);
}
+ [Fact]
+ public async Task ThrottledPendingRevisionStartsWithoutAnotherSubmissionSignal()
+ {
+ var session = new TrackAuthoringSession(
+ CreateStraightState(length: 10.0, banking: null));
+ InteractiveAuthoringTransaction transaction = Begin(session, "straight");
+ await using var coordinator = new TrackAuthoringEvaluationCoordinator(
+ session,
+ new AuthoringEvaluationSchedulerOptions(
+ AuthoringEvaluationExecutionMode.SerializedBackground,
+ TimeSpan.FromMilliseconds(40)));
+
+ await coordinator.SubmitProvisionalEdit(
+ transaction.Revision,
+ TrackAuthoringCandidateOperation.Replace(
+ "straight",
+ new StraightSectionDefinition("straight", 11.0))).Completion;
+ await coordinator.SubmitProvisionalEdit(
+ transaction.Revision,
+ TrackAuthoringCandidateOperation.Replace(
+ "straight",
+ new StraightSectionDefinition("straight", 12.0))).Completion;
+ AuthoringEvaluationOutcome final = await coordinator.SubmitProvisionalEdit(
+ transaction.Revision,
+ TrackAuthoringCandidateOperation.Replace(
+ "straight",
+ new StraightSectionDefinition("straight", 13.0)))
+ .Completion
+ .WaitAsync(TimeSpan.FromSeconds(5));
+
+ Assert.Equal(AuthoringEvaluationOutcomeStatus.Accepted, final.Status);
+ Assert.Equal(3, coordinator.CaptureSnapshot().Started);
+ Assert.Equal(0, coordinator.CaptureSnapshot().PendingDepth);
+ }
+
[Fact]
public async Task ProductionBackgroundPipelineNeverOverlapsCandidateWork()
{
diff --git a/Quantum.Tests/Editor/M167InspectorHeadlessTests.cs b/Quantum.Tests/Editor/M167InspectorHeadlessTests.cs
new file mode 100644
index 0000000..a18f376
--- /dev/null
+++ b/Quantum.Tests/Editor/M167InspectorHeadlessTests.cs
@@ -0,0 +1,493 @@
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Controls.Primitives;
+using Avalonia.Headless;
+using Avalonia.Input;
+using Avalonia.Interactivity;
+using Avalonia.Threading;
+using Avalonia.VisualTree;
+using Quantum.Editor.Avalonia;
+using Quantum.Editor.Avalonia.Controls;
+using Quantum.Editor.Avalonia.Models;
+using Quantum.Editor.Avalonia.Services;
+using Quantum.Editor.Avalonia.Services.Authoring;
+using Quantum.Editor.Avalonia.Services.Commands;
+using Quantum.Editor.Avalonia.Services.Documents;
+using Quantum.IO.TrackLayout.V2;
+using Quantum.Math;
+using Quantum.Track.Authoring;
+
+namespace Quantum.Tests.Editor;
+
+[CollectionDefinition(Name, DisableParallelization = true)]
+public sealed class AvaloniaHeadlessCollection : ICollectionFixture
+{
+ public const string Name = "Avalonia headless";
+}
+
+[Collection(AvaloniaHeadlessCollection.Name)]
+public sealed class M167ViewportHeadlessTests
+{
+ private readonly HeadlessUnitTestSession session;
+
+ public M167ViewportHeadlessTests(AvaloniaHeadlessFixture fixture)
+ {
+ session = fixture.Session;
+ }
+
+ [Fact]
+ public async Task SnapshotReplacementPreservesProjectionCenterPanAndScale()
+ {
+ await session.Dispatch(
+ () =>
+ {
+ var viewport = new TrackViewportControl
+ {
+ Snapshot = Snapshot(10.0),
+ Projection = TrackViewportProjection.Side
+ };
+ viewport.SetCameraState(
+ new Point(12.0, -4.0),
+ new Vector(23.0, 17.0),
+ 8.5);
+ TrackViewportCameraState before = viewport.CaptureCameraState();
+
+ viewport.Snapshot = Snapshot(18.0);
+
+ TrackViewportCameraState after = viewport.CaptureCameraState();
+ Assert.Equal(before, after);
+ Assert.False(after.FitPending);
+ return true;
+ },
+ CancellationToken.None);
+ }
+
+ [Fact]
+ public async Task ExplicitFitAndInitialDocumentPresentationRequestFit()
+ {
+ await session.Dispatch(
+ () =>
+ {
+ var viewport = new TrackViewportControl { Snapshot = Snapshot(10.0) };
+ viewport.SetCameraState(new Point(2.0, 3.0), new Vector(4.0, 5.0), 7.0);
+ Assert.False(viewport.CaptureCameraState().FitPending);
+
+ viewport.FitToTrack();
+ Assert.True(viewport.CaptureCameraState().FitPending);
+
+ viewport.SetCameraState(new Point(2.0, 3.0), new Vector(4.0, 5.0), 7.0);
+ viewport.BeginDocumentPresentation();
+ viewport.Snapshot = Snapshot(20.0);
+ Assert.True(viewport.CaptureCameraState().FitPending);
+ return true;
+ },
+ CancellationToken.None);
+ }
+
+ private static TrackViewportSnapshot Snapshot(double length)
+ {
+ return new TrackViewportSnapshot(
+ new[]
+ {
+ new TrackViewportSample(
+ 0,
+ 0,
+ 0.0,
+ Vector3d.Zero,
+ Vector3d.UnitX,
+ Vector3d.UnitY,
+ Vector3d.UnitZ,
+ 0.0,
+ 0.0),
+ new TrackViewportSample(
+ 1,
+ 0,
+ length,
+ new Vector3d(length, 0.0, 0.0),
+ Vector3d.UnitX,
+ Vector3d.UnitY,
+ Vector3d.UnitZ,
+ 0.0,
+ 0.0)
+ },
+ length,
+ 0.0,
+ 0.0,
+ Array.Empty(),
+ continuityReport: null);
+ }
+}
+
+public sealed class AvaloniaHeadlessFixture : IDisposable
+{
+ internal HeadlessUnitTestSession Session { get; } =
+ HeadlessUnitTestSession.StartNew(typeof(App));
+
+ public void Dispose() => Session.Dispose();
+}
+
+[Collection(AvaloniaHeadlessCollection.Name)]
+public sealed class M167InspectorHeadlessTests
+{
+ private readonly HeadlessUnitTestSession session;
+
+ public M167InspectorHeadlessTests(AvaloniaHeadlessFixture fixture)
+ {
+ session = fixture.Session;
+ }
+
+ [Fact]
+ public async Task PointerGestureUsesAbsoluteBaseCommitsNewestAndSurvivesPreviewRefresh()
+ {
+ await DispatchAsync(async () =>
+ {
+ using Host host = CreateHost();
+ StraightLengthScrubberControl scrubber = host.Scrubber;
+ Point origin = CenterInWindow(scrubber, host.Window);
+
+ host.Window.MouseDown(origin, MouseButton.Left, RawInputModifiers.None);
+ Assert.True(host.Workspace.IsInteractiveEditActive);
+ await WaitUntilAsync(() =>
+ host.Workspace.StraightLengthEdit?.AcceptedPreviewLength == 30.0);
+
+ host.Window.MouseMove(
+ new Point(origin.X + 50.0, origin.Y),
+ RawInputModifiers.None);
+ await WaitUntilAsync(() =>
+ host.Workspace.StraightLengthEdit?.AcceptedPreviewLength == 35.0);
+ Assert.Same(scrubber, host.Inspector.LengthScrubber);
+ Assert.False(host.Workspace.UndoRedo.CanUndo);
+
+ host.Window.MouseMove(
+ new Point(origin.X + 20.0, origin.Y),
+ RawInputModifiers.None);
+ Assert.Equal(32.0, host.Workspace.StraightLengthEdit!.RawLength, 9);
+ await WaitUntilAsync(() =>
+ host.Workspace.StraightLengthEdit?.AcceptedPreviewLength == 32.0);
+ Assert.Equal(32.0, host.Workspace.StraightLengthEdit!.RawLength, 9);
+ Assert.Equal("launch", host.Workspace.CurrentSelection!.NodeId);
+ Assert.False(host.Workspace.UndoRedo.CanUndo);
+
+ host.Window.MouseUp(
+ new Point(origin.X + 20.0, origin.Y),
+ MouseButton.Left,
+ RawInputModifiers.None);
+ await WaitUntilAsync(() => !host.Workspace.IsInteractiveEditActive);
+
+ Assert.Equal(32.0, StraightLength(host.Document), 9);
+ Assert.True(host.Workspace.UndoRedo.CanUndo);
+ Assert.Equal(2, host.Workspace.CaptureStraightLengthMetrics().RawPointerUpdates);
+ Assert.True(host.Workspace.UndoLast());
+ Assert.Equal(30.0, StraightLength(host.Document), 9);
+ });
+ }
+
+ [Fact]
+ public async Task ShiftUsesFineSensitivityFromOriginalAuthoredLength()
+ {
+ await DispatchAsync(async () =>
+ {
+ using Host host = CreateHost();
+ Point origin = CenterInWindow(host.Scrubber, host.Window);
+ host.Window.MouseDown(origin, MouseButton.Left, RawInputModifiers.None);
+
+ host.Window.MouseMove(
+ new Point(origin.X + 50.0, origin.Y),
+ RawInputModifiers.Shift);
+ await WaitUntilAsync(() =>
+ host.Workspace.StraightLengthEdit?.AcceptedPreviewLength == 30.5);
+
+ Assert.Equal(30.5, host.Workspace.StraightLengthEdit!.RawLength, 9);
+ host.Scrubber.Cancel();
+ await WaitUntilAsync(() => !host.Workspace.IsInteractiveEditActive);
+ });
+ }
+
+ [Fact]
+ public async Task EscapeAndPointerCaptureLossCancelWithoutHistory()
+ {
+ await DispatchAsync(async () =>
+ {
+ using Host escapeHost = CreateHost();
+ Point escapeOrigin = CenterInWindow(escapeHost.Scrubber, escapeHost.Window);
+ escapeHost.Window.MouseDown(
+ escapeOrigin,
+ MouseButton.Left,
+ RawInputModifiers.None);
+ escapeHost.Window.MouseMove(
+ new Point(escapeOrigin.X + 30.0, escapeOrigin.Y),
+ RawInputModifiers.None);
+ escapeHost.Window.KeyPress(
+ Key.Escape,
+ RawInputModifiers.None,
+ PhysicalKey.Escape,
+ null);
+ await WaitUntilAsync(() => !escapeHost.Workspace.IsInteractiveEditActive);
+ Assert.Equal(30.0, StraightLength(escapeHost.Document), 9);
+ Assert.False(escapeHost.Workspace.UndoRedo.CanUndo);
+
+ using Host captureHost = CreateHost();
+ Point captureOrigin = CenterInWindow(captureHost.Scrubber, captureHost.Window);
+ captureHost.Window.MouseDown(
+ captureOrigin,
+ MouseButton.Left,
+ RawInputModifiers.None);
+ captureHost.Scrubber.ReleasePointerCapture();
+ await WaitUntilAsync(() => !captureHost.Workspace.IsInteractiveEditActive);
+ Assert.Equal(30.0, StraightLength(captureHost.Document), 9);
+ Assert.False(captureHost.Workspace.UndoRedo.CanUndo);
+ });
+ }
+
+ [Fact]
+ public async Task InvalidRawLengthShowsStatusAndReleaseRestoresCommittedPresentation()
+ {
+ await DispatchAsync(async () =>
+ {
+ using Host host = CreateHost();
+ Point origin = CenterInWindow(host.Scrubber, host.Window);
+ host.Window.MouseDown(origin, MouseButton.Left, RawInputModifiers.None);
+ host.Window.MouseMove(
+ new Point(origin.X - 400.0, origin.Y),
+ RawInputModifiers.None);
+ await WaitUntilAsync(() =>
+ host.Workspace.StraightLengthEdit?.Status == StraightLengthEditStatus.Invalid);
+
+ Assert.Equal(-10.0, host.Workspace.StraightLengthEdit!.RawLength, 9);
+ Assert.Contains("Last valid preview", host.Inspector.LengthScrubStatusText);
+ Assert.NotEmpty(host.Workspace.StraightLengthEdit.Diagnostics);
+ Assert.Equal(30.0, host.Workspace.ViewportSnapshot.TotalLength, 9);
+
+ host.Window.MouseUp(
+ new Point(origin.X - 400.0, origin.Y),
+ MouseButton.Left,
+ RawInputModifiers.None);
+ await WaitUntilAsync(() => !host.Workspace.IsInteractiveEditActive);
+
+ Assert.Equal(30.0, StraightLength(host.Document), 9);
+ Assert.Equal(30.0, host.Workspace.ViewportSnapshot.TotalLength, 9);
+ Assert.False(host.Workspace.UndoRedo.CanUndo);
+ });
+ }
+
+ [Fact]
+ public async Task NewestPreviewWinsAndSourceCommandsAreDisabledDuringGesture()
+ {
+ await DispatchAsync(async () =>
+ {
+ using Host host = CreateHost();
+ Point origin = CenterInWindow(host.Scrubber, host.Window);
+ host.Window.MouseDown(origin, MouseButton.Left, RawInputModifiers.None);
+ for (int delta = 5; delta <= 80; delta += 5)
+ {
+ host.Window.MouseMove(
+ new Point(origin.X + delta, origin.Y),
+ RawInputModifiers.None);
+ }
+
+ Assert.False(host.Workspace.Commands.CanExecute(EditorCommandIds.SaveDocument));
+ Assert.False(host.Workspace.Commands.CanExecute(EditorCommandIds.Undo));
+ Assert.False(host.Workspace.Commands.CanExecute(EditorCommandIds.Redo));
+ Assert.False(host.Workspace.Commands.CanExecute(EditorCommandIds.NewDocument));
+ Assert.Equal("launch", host.Workspace.CurrentSelection!.NodeId);
+ host.Window.MouseUp(
+ new Point(origin.X + 80.0, origin.Y),
+ MouseButton.Left,
+ RawInputModifiers.None);
+ await WaitUntilAsync(() => !host.Workspace.IsInteractiveEditActive);
+ Assert.Equal(38.0, host.Workspace.ViewportSnapshot.TotalLength, 9);
+ TrackViewportSnapshot committedSnapshot = host.Workspace.ViewportSnapshot;
+ await Task.Delay(100);
+ Dispatcher.UIThread.RunJobs();
+ Assert.Same(committedSnapshot, host.Workspace.ViewportSnapshot);
+ Assert.Equal(38.0, StraightLength(host.Document), 9);
+ Assert.True(host.Workspace.UndoRedo.CanUndo);
+ });
+ }
+
+ [Fact]
+ public async Task RouteSourceMutationAndSelectionButtonsDisableDuringGesturePolicy()
+ {
+ await DispatchAsync(async () =>
+ {
+ using Host host = CreateHost();
+ Point origin = CenterInWindow(host.Scrubber, host.Window);
+ host.Window.MouseDown(origin, MouseButton.Left, RawInputModifiers.None);
+
+ var route = new RoutePaneControl
+ {
+ GraphNodes = host.Workspace.GraphNodes,
+ Selection = host.Workspace.CurrentSelection,
+ SourceEditingEnabled = false
+ };
+ var routeWindow = new Window { Width = 400, Height = 600, Content = route };
+ try
+ {
+ routeWindow.Show();
+ Dispatcher.UIThread.RunJobs();
+ Assert.All(
+ route.GetVisualDescendants().OfType