diff --git a/Quantum.Application/Authoring/AuthoringEvaluationScheduling.cs b/Quantum.Application/Authoring/AuthoringEvaluationScheduling.cs new file mode 100644 index 0000000..411994b --- /dev/null +++ b/Quantum.Application/Authoring/AuthoringEvaluationScheduling.cs @@ -0,0 +1,364 @@ +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, + IEnumerable submitToPresentSamples, + long discardedPostLifecycleCompletions, + long abandonedFinalCommits) + { + 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; + SubmitToPresentSamples = new List( + submitToPresentSamples ?? + throw new ArgumentNullException(nameof(submitToPresentSamples))) + .AsReadOnly(); + DiscardedPostLifecycleCompletions = discardedPostLifecycleCompletions; + AbandonedFinalCommits = abandonedFinalCommits; + } + + 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; } + public IReadOnlyList SubmitToPresentSamples { get; } + public long RejectedStaleCompletions => Stale; + public long DiscardedPostLifecycleCompletions { get; } + public long AbandonedFinalCommits { get; } + } +} 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..ec06ebe --- /dev/null +++ b/Quantum.Application/Authoring/InteractiveAuthoringTransaction.cs @@ -0,0 +1,118 @@ +using System; + +namespace Quantum.Application.Authoring +{ + public sealed class InteractiveAuthoringTransaction + { + internal InteractiveAuthoringTransaction( + TransactionRevision revision, + CommittedSourceRevision baseCommittedRevision, + PreparedTrackGraphState beforeState, + string targetSectionId, + string parameterIdentity, + string description, + ProvisionalEditRevision? newestProvisionalRevision, + 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)); + NewestProvisionalRevision = newestProvisionalRevision; + 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 { get; } + + /// + /// 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 && + 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) + { + if (candidate is null) + { + 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, + BeforeState, + TargetSectionId, + ParameterIdentity, + Description, + NewestProvisionalRevision, + 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/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/Authoring/TrackAuthoringEvaluationCoordinator.cs b/Quantum.Application/Authoring/TrackAuthoringEvaluationCoordinator.cs new file mode 100644 index 0000000..031304e --- /dev/null +++ b/Quantum.Application/Authoring/TrackAuthoringEvaluationCoordinator.cs @@ -0,0 +1,817 @@ +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 bool frozenCommitAbandonmentRecorded; + + 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; + private long discardedPostLifecycleCompletions; + private long abandonedFinalCommits; + private readonly List submitToPresentSamples = + new List(); + + 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; + frozenCommitAbandonmentRecorded = false; + if (candidate is null) + { + if (!outstanding.TryGetValue(provisionalRevision, out work)) + { + frozenCommitTransaction = null; + frozenCommitRevision = null; + frozenCommitAbandonmentRecorded = false; + 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; + frozenCommitAbandonmentRecorded = false; + } + } + } + } + + public bool CancelTransaction(TransactionRevision transactionRevision) + { + WorkItem? pendingToComplete = null; + lock (gate) + { + ThrowIfDisposed(); + if (frozenCommitTransaction == transactionRevision && + !frozenCommitAbandonmentRecorded) + { + abandonedFinalCommits++; + frozenCommitAbandonmentRecorded = true; + } + + 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, + submitToPresentSamples.ToArray(), + discardedPostLifecycleCompletions, + abandonedFinalCommits); + } + } + + public void Dispose() + { + DisposeAsync().AsTask().GetAwaiter().GetResult(); + } + + public async ValueTask DisposeAsync() + { + WorkItem? pendingToComplete; + Task? worker; + lock (gate) + { + if (disposed) + { + return; + } + + disposed = true; + if (frozenCommitTransaction.HasValue && + !frozenCommitAbandonmentRecorded) + { + abandonedFinalCommits++; + frozenCommitAbandonmentRecorded = 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) + { + using (var waitCancellation = + CancellationTokenSource.CreateLinkedTokenSource(shutdown.Token)) + try + { + 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) + { + } + + Task interrupted = ReferenceEquals(completed, delayTask) + ? signalTask + : delayTask; + try + { + await interrupted.ConfigureAwait(false); + } + catch (OperationCanceledException) when ( + !shutdown.IsCancellationRequested) + { + } + } + 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) + { + if (product != null && shutdown.IsCancellationRequested) + { + lock (gate) + { + discardedPostLifecycleCompletions++; + } + } + + 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; + if (outcome.Timing.SubmitToPresentTime.HasValue) + { + submitToPresentSamples.Add(outcome.Timing.SubmitToPresentTime.Value); + } + + 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 new file mode 100644 index 0000000..a7b87c0 --- /dev/null +++ b/Quantum.Application/Authoring/TrackAuthoringSession.cs @@ -0,0 +1,668 @@ +using System; +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 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; + 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 + { + get { lock (syncRoot) { return sessionId; } } + } + + public CommittedTrackState CommittedState + { + get { lock (syncRoot) { return committedState; } } + } + + public PreparedTrackGraphState PresentedState + { + get { lock (syncRoot) { return presentedState; } } + } + + public InteractiveAuthoringTransaction? ActiveTransaction + { + get { lock (syncRoot) { return activeTransaction; } } + } + + public AuthoringHistory History { get; } + + public bool CanUndo + { + get { lock (syncRoot) { return History.CanUndo; } } + } + + public bool CanRedo + { + get { lock (syncRoot) { return History.CanRedo; } } + } + + public string? UndoDescription + { + get { lock (syncRoot) { return History.UndoDescription; } } + } + + public string? RedoDescription + { + get { lock (syncRoot) { return History.RedoDescription; } } + } + + public bool HasCleanBaseline + { + get { lock (syncRoot) { return hasCleanBaseline; } } + } + + public string? CleanCanonicalPackageBaseline + { + get { lock (syncRoot) { return cleanCanonicalPackageJson; } } + } + + public string? PersistableCanonicalPackageJson + { + get { lock (syncRoot) { return committedState.CanonicalPackageJson; } } + } + + public bool IsDirty + { + get { lock (syncRoot) { return IsDirtyCore(); } } + } + + public static TrackAuthoringSession Create( + TrackAuthoringGraph graph, + TrackLayoutPackageV2GraphAncillaryState ancillaryState, + bool markClean = true) + { + return new TrackAuthoringSession( + PreparedTrackGraphState.Prepare(graph, ancillaryState), + markClean); + } + + public AuthoringSessionSnapshot CaptureSnapshot() + { + lock (syncRoot) + { + return new AuthoringSessionSnapshot( + sessionId, + committedState, + presentedState, + activeTransaction, + IsDirtyCore(), + 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) + { + lock (syncRoot) + { + 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, + newestProvisionalRevision: null, + 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)); + } + + AuthoringCandidateReservationResult reservation; + lock (syncRoot) + { + synchronousRequestRevisionSequence++; + reservation = ReserveCandidateCore( + transactionRevision, + operation, + new EvaluationRequestRevision( + sessionId, + synchronousRequestRevisionSequence), + DateTimeOffset.UtcNow); + } + + if (reservation.Request is null) + { + return new CandidateUpdateResult( + wasEvaluated: false, + candidate: null, + reservation.Diagnostics); + } + + 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) + { + throw new ArgumentNullException(nameof(operation)); + } + + lock (syncRoot) + { + return ReserveCandidateCore( + transactionRevision, + operation, + requestRevision, + submittedAtUtc); + } + } + + internal bool IsEvaluationRequestCurrent(AuthoringEvaluationRequest request) + { + if (request is null) + { + throw new ArgumentNullException(nameof(request)); + } + + lock (syncRoot) + { + return IsEvaluationRequestCurrentCore(request); + } + } + + internal AuthoringCandidatePublicationResult PublishCandidate( + TrackCandidateEvaluationProduct product) + { + if (product is null) + { + throw new ArgumentNullException(nameof(product)); + } + + lock (syncRoot) + { + EvaluatedTrackCandidate candidate = product.Candidate; + if (!IsCandidateRevisionCurrentCore(candidate.Revision)) + { + 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.") + })); + } + + activeTransaction = activeTransaction!.WithCandidate(candidate); + if (candidate.IsCommitEligible) + { + presentedState = candidate.PreparedState!; + } + + return new AuthoringCandidatePublicationResult( + acceptedBySession: true, + new CandidateUpdateResult( + wasEvaluated: true, + candidate, + candidate.ApplicationDiagnostics)); + } + } + + internal bool TryGetNewestCandidate( + TransactionRevision transactionRevision, + out EvaluatedCandidateRevision candidateRevision, + out EvaluatedTrackCandidate? candidate) + { + lock (syncRoot) + { + if (activeTransaction is null || + activeTransaction.Revision != transactionRevision || + !activeTransaction.NewestProvisionalRevision.HasValue) + { + candidateRevision = default; + candidate = null; + return false; + } + + 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) + { + lock (syncRoot) + { + 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 (!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."); + } + + 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) + { + lock (syncRoot) + { + if (activeTransaction is null || + activeTransaction.Revision != transactionRevision) + { + return false; + } + + activeTransaction = null; + presentedState = committedState.PreparedState; + return true; + } + } + + public bool Undo() + { + lock (syncRoot) + { + if (activeTransaction != null || !History.TryUndo(out AuthoringHistoryEntry? entry)) + { + return false; + } + + AdoptCommittedState(entry!.BeforeState); + return true; + } + } + + public bool Redo() + { + lock (syncRoot) + { + if (activeTransaction != null || !History.TryRedo(out AuthoringHistoryEntry? entry)) + { + return false; + } + + AdoptCommittedState(entry!.AfterState); + return true; + } + } + + /// + /// Adopts an already validated and persistence-ready state as one logical + /// editor command. This is the bridge used by existing one-shot editor + /// commands so interactive and non-interactive changes share this session's + /// single authoritative history. + /// + public AuthoringCommitResult CommitPreparedEdit( + string operationDescription, + PreparedTrackGraphState preparedState) + { + if (string.IsNullOrWhiteSpace(operationDescription)) + { + throw new ArgumentException( + "An operation description is required.", + nameof(operationDescription)); + } + + if (preparedState is null) + { + throw new ArgumentNullException(nameof(preparedState)); + } + + lock (syncRoot) + { + if (activeTransaction != null) + { + return RejectedCommit( + AuthoringSessionDiagnosticCode.TransactionActive, + "A prepared editor command cannot commit during an active transaction."); + } + + PreparedTrackGraphState beforeState = committedState.PreparedState; + if (beforeState.HasSameCanonicalContent(preparedState)) + { + presentedState = beforeState; + return new AuthoringCommitResult( + succeeded: true, + changed: false, + committedState, + Array.Empty()); + } + + History.Record(new AuthoringHistoryEntry( + operationDescription, + beforeState, + preparedState)); + AdoptCommittedState(preparedState); + return new AuthoringCommitResult( + succeeded: true, + changed: true, + committedState, + Array.Empty()); + } + } + + public void ClearHistory() + { + lock (syncRoot) + { + if (activeTransaction != null) + { + throw new InvalidOperationException( + "History cannot be cleared during an active transaction."); + } + + History.Clear(); + } + } + + public void MarkClean() + { + lock (syncRoot) + { + 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)); + } + + 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( + 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 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++; + 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 bool IsDirtyCore() + { + return !hasCleanBaseline || + !string.Equals( + cleanCanonicalPackageJson, + committedState.CanonicalPackageJson, + StringComparison.Ordinal); + } + + private static AuthoringCandidateReservationResult RejectedReservation( + AuthoringSessionDiagnosticCode code, + string message) + { + return new AuthoringCandidateReservationResult( + request: 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.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 new file mode 100644 index 0000000..346b5b7 --- /dev/null +++ b/Quantum.Application/Quantum.Application.csproj @@ -0,0 +1,19 @@ + + + + netstandard2.1 + enable + + + + + <_Parameter1>Quantum.Tests + + + + + + + + + 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..7e1d48d 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,13 @@ 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 keyboardOffsetFromStart; + private double? lastSubmittedLength; + private bool restoreLengthScrubberKeyboardFocus; public InspectorPaneControl() { @@ -28,6 +39,12 @@ public InspectorPaneControl() public void Refresh(EditorWorkspace editorWorkspace) { workspace = editorWorkspace ?? throw new ArgumentNullException(nameof(editorWorkspace)); + if (CanRefreshActiveScrubInPlace(editorWorkspace)) + { + RefreshActiveScrubPresentation(); + return; + } + RebuildInspector(); } @@ -36,6 +53,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 +143,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 +172,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 +444,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 +466,237 @@ 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.IsEnabled = !GetWorkspace().IsInteractiveEditActive; + scrubber.ScrubStarted += OnLengthScrubStarted; + scrubber.ScrubDelta += OnLengthScrubDelta; + scrubber.KeyboardIncrementRequested += OnLengthKeyboardIncrementRequested; + scrubber.CommitRequested += OnLengthScrubCommitRequested; + scrubber.CancelRequested += OnLengthScrubCancelRequested; + if (restoreLengthScrubberKeyboardFocus) + { + Dispatcher.UIThread.Post(() => + { + if (scrubber.IsEffectivelyEnabled) + { + scrubber.Focus(); + } + }); + } + + 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; + keyboardOffsetFromStart = 0.0; + lastSubmittedLength = null; + SubmitLengthPreview(scrubStartLength); + } + + private void OnLengthScrubDelta( + object? sender, + StraightLengthScrubDeltaEventArgs eventArgs) + { + EditorWorkspace currentWorkspace = GetWorkspace(); + currentWorkspace.RecordStraightLengthPointerUpdate(); + double sensitivity = currentWorkspace.StraightLengthScrubSensitivity.Resolve( + (eventArgs.Modifiers & KeyModifiers.Shift) != 0, + (eventArgs.Modifiers & KeyModifiers.Control) != 0); + double absoluteLength = + scrubStartLength + (eventArgs.TotalHorizontalDelta * sensitivity); + SubmitLengthPreview(absoluteLength); + } + + private void OnLengthKeyboardIncrementRequested( + object? sender, + StraightLengthKeyboardIncrementEventArgs eventArgs) + { + EditorWorkspace currentWorkspace = GetWorkspace(); + double sensitivity = currentWorkspace.StraightLengthScrubSensitivity.Resolve( + (eventArgs.Modifiers & KeyModifiers.Shift) != 0, + (eventArgs.Modifiers & KeyModifiers.Control) != 0); + keyboardOffsetFromStart += eventArgs.Direction * sensitivity; + SubmitLengthPreview(scrubStartLength + keyboardOffsetFromStart); + } + + 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, + StraightLengthScrubCommitEventArgs eventArgs) + { + restoreLengthScrubberKeyboardFocus = eventArgs.WasKeyboardGesture; + 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); + } + finally + { + restoreLengthScrubberKeyboardFocus = false; + } + } + + private void OnLengthScrubCancelRequested( + object? sender, + StraightLengthScrubCancelEventArgs eventArgs) + { + restoreLengthScrubberKeyboardFocus = eventArgs.WasKeyboardGesture; + try + { + GetWorkspace().CancelStraightLengthEdit(); + } + finally + { + restoreLengthScrubberKeyboardFocus = false; + } + } + + 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 ↔ or use Left/Right. Enter commits; Escape cancels. 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..4294091 --- /dev/null +++ b/Quantum.Editor.Avalonia/Controls/StraightLengthScrubberControl.cs @@ -0,0 +1,295 @@ +using Avalonia; +using Avalonia.Automation; +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; } +} + +internal sealed class StraightLengthKeyboardIncrementEventArgs : EventArgs +{ + internal StraightLengthKeyboardIncrementEventArgs(int direction, KeyModifiers modifiers) + { + Direction = direction; + Modifiers = modifiers; + } + + internal int Direction { get; } + + internal KeyModifiers Modifiers { get; } +} + +internal sealed class StraightLengthScrubCommitEventArgs : EventArgs +{ + internal StraightLengthScrubCommitEventArgs(bool wasKeyboardGesture) + { + WasKeyboardGesture = wasKeyboardGesture; + } + + internal bool WasKeyboardGesture { get; } +} + +internal sealed class StraightLengthScrubCancelEventArgs : EventArgs +{ + internal StraightLengthScrubCancelEventArgs(bool wasKeyboardGesture) + { + WasKeyboardGesture = wasKeyboardGesture; + } + + internal bool WasKeyboardGesture { 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; + private ScrubInputKind inputKind; + + 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 or use Left/Right to preview Length. Enter commits; Escape cancels. Shift: fine, Ctrl: coarse."); + AutomationProperties.SetAutomationId(this, "straightLengthLiveEditor"); + AutomationProperties.SetName(this, "Straight section length live editor"); + AutomationProperties.SetHelpText( + this, + "Use Left or Right Arrow to adjust length. Shift is fine, Control is coarse, Enter commits, and Escape cancels."); + LostFocus += (_, _) => + { + if (IsKeyboardScrubbing) + { + Cancel(); + } + }; + 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? + KeyboardIncrementRequested; + + internal event EventHandler? CommitRequested; + + internal event EventHandler? CancelRequested; + + internal bool IsScrubbing { get; private set; } + + internal bool IsKeyboardScrubbing => + IsScrubbing && inputKind == ScrubInputKind.Keyboard; + + 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; + inputKind = ScrubInputKind.Pointer; + 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 || inputKind != ScrubInputKind.Pointer) + { + 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 || inputKind != ScrubInputKind.Pointer || + eventArgs.InitialPressMouseButton != MouseButton.Left) + { + return; + } + + EndScrub(); + IsEnabled = false; + CommitRequested?.Invoke( + this, + new StraightLengthScrubCommitEventArgs(wasKeyboardGesture: false)); + eventArgs.Handled = true; + } + + protected override void OnKeyDown(KeyEventArgs eventArgs) + { + base.OnKeyDown(eventArgs); + if (eventArgs.Key == Key.Escape && IsScrubbing) + { + Cancel(); + eventArgs.Handled = true; + return; + } + + if (eventArgs.Key == Key.Enter && IsKeyboardScrubbing) + { + EndScrub(); + IsEnabled = false; + CommitRequested?.Invoke( + this, + new StraightLengthScrubCommitEventArgs(wasKeyboardGesture: true)); + eventArgs.Handled = true; + return; + } + + if (eventArgs.Key != Key.Left && eventArgs.Key != Key.Right) + { + return; + } + + if (!IsScrubbing) + { + IsScrubbing = true; + inputKind = ScrubInputKind.Keyboard; + ScrubStarted?.Invoke(this, EventArgs.Empty); + } + + if (IsKeyboardScrubbing) + { + KeyboardIncrementRequested?.Invoke( + this, + new StraightLengthKeyboardIncrementEventArgs( + eventArgs.Key == Key.Right ? 1 : -1, + eventArgs.KeyModifiers)); + eventArgs.Handled = true; + } + } + + protected override void OnPointerCaptureLost(PointerCaptureLostEventArgs eventArgs) + { + base.OnPointerCaptureLost(eventArgs); + if (IsScrubbing && inputKind == ScrubInputKind.Pointer && !releasingCapture) + { + IsScrubbing = false; + inputKind = ScrubInputKind.None; + capturedPointer = null; + CancelRequested?.Invoke( + this, + new StraightLengthScrubCancelEventArgs(wasKeyboardGesture: false)); + } + } + + protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs eventArgs) + { + if (IsScrubbing) + { + Cancel(); + } + + base.OnDetachedFromVisualTree(eventArgs); + } + + internal void Cancel() + { + if (!IsScrubbing) + { + return; + } + + bool wasKeyboardGesture = IsKeyboardScrubbing; + EndScrub(); + CancelRequested?.Invoke( + this, + new StraightLengthScrubCancelEventArgs(wasKeyboardGesture)); + } + + internal void ReleasePointerCapture() + { + capturedPointer?.Capture(null); + } + + private void EndScrub() + { + if (!IsScrubbing) + { + return; + } + + releasingCapture = true; + try + { + IsScrubbing = false; + inputKind = ScrubInputKind.None; + capturedPointer?.Capture(null); + capturedPointer = null; + } + finally + { + releasingCapture = false; + } + } + + private enum ScrubInputKind + { + None, + Pointer, + Keyboard + } +} 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.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/Authoring/LatencyPercentiles.cs b/Quantum.Editor.Avalonia/Services/Authoring/LatencyPercentiles.cs new file mode 100644 index 0000000..fd1ceaa --- /dev/null +++ b/Quantum.Editor.Avalonia/Services/Authoring/LatencyPercentiles.cs @@ -0,0 +1,49 @@ +namespace Quantum.Editor.Avalonia.Services.Authoring; + +/// +/// Nearest-rank latency percentiles for observational live-edit measurements. +/// Empty samples produce a zero-count, zero-duration summary. +/// +public sealed record LatencyPercentileSummary( + int SampleCount, + TimeSpan P50, + TimeSpan P95, + TimeSpan P99); + +public static class LatencyPercentiles +{ + public static LatencyPercentileSummary Calculate(IEnumerable samples) + { + ArgumentNullException.ThrowIfNull(samples); + TimeSpan[] ordered = samples.OrderBy(sample => sample).ToArray(); + if (ordered.Length == 0) + { + return new LatencyPercentileSummary( + 0, + TimeSpan.Zero, + TimeSpan.Zero, + TimeSpan.Zero); + } + + if (ordered[0] < TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException( + nameof(samples), + "Latency samples cannot be negative."); + } + + return new LatencyPercentileSummary( + ordered.Length, + NearestRank(ordered, 0.50), + NearestRank(ordered, 0.95), + NearestRank(ordered, 0.99)); + } + + private static TimeSpan NearestRank(TimeSpan[] ordered, double percentile) + { + int index = System.Math.Max( + 0, + (int)System.Math.Ceiling(percentile * ordered.Length) - 1); + return ordered[index]; + } +} diff --git a/Quantum.Editor.Avalonia/Services/Authoring/StraightLengthEditState.cs b/Quantum.Editor.Avalonia/Services/Authoring/StraightLengthEditState.cs new file mode 100644 index 0000000..1a54a0b --- /dev/null +++ b/Quantum.Editor.Avalonia/Services/Authoring/StraightLengthEditState.cs @@ -0,0 +1,99 @@ +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, + IReadOnlyList SubmitToPresentLatencySamples, + IReadOnlyList FinalCommitWaitLatencySamples) +{ + public LatencyPercentileSummary SubmitToPresentPercentiles => + LatencyPercentiles.Calculate(SubmitToPresentLatencySamples); + + public LatencyPercentileSummary FinalCommitWaitPercentiles => + LatencyPercentiles.Calculate(FinalCommitWaitLatencySamples); +} + +/// +/// 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/Authoring/StraightLengthScrubSensitivity.cs b/Quantum.Editor.Avalonia/Services/Authoring/StraightLengthScrubSensitivity.cs new file mode 100644 index 0000000..4f09246 --- /dev/null +++ b/Quantum.Editor.Avalonia/Services/Authoring/StraightLengthScrubSensitivity.cs @@ -0,0 +1,48 @@ +namespace Quantum.Editor.Avalonia.Services.Authoring; + +/// +/// Small injectable policy shared by pointer and keyboard straight-length +/// scrubbing. Values are metres per pointer pixel or keyboard step. +/// +public sealed class StraightLengthScrubSensitivity +{ + public StraightLengthScrubSensitivity( + double normalMetersPerStep = 0.1, + double fineMetersPerStep = 0.01, + double coarseMetersPerStep = 1.0) + { + NormalMetersPerStep = RequirePositiveFinite( + normalMetersPerStep, + nameof(normalMetersPerStep)); + FineMetersPerStep = RequirePositiveFinite( + fineMetersPerStep, + nameof(fineMetersPerStep)); + CoarseMetersPerStep = RequirePositiveFinite( + coarseMetersPerStep, + nameof(coarseMetersPerStep)); + } + + public static StraightLengthScrubSensitivity Default { get; } = new(); + + public double NormalMetersPerStep { get; } + + public double FineMetersPerStep { get; } + + public double CoarseMetersPerStep { get; } + + public double Resolve(bool shift, bool control) => shift + ? FineMetersPerStep + : control ? CoarseMetersPerStep : NormalMetersPerStep; + + private static double RequirePositiveFinite(double value, string parameterName) + { + if (!double.IsFinite(value) || value <= 0.0) + { + throw new ArgumentOutOfRangeException( + parameterName, + "Scrub sensitivity must be finite and greater than zero."); + } + + return value; + } +} diff --git a/Quantum.Editor.Avalonia/Services/Documents/TrackEditorDocument.cs b/Quantum.Editor.Avalonia/Services/Documents/TrackEditorDocument.cs index 65d78e1..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; @@ -8,6 +9,7 @@ public sealed class TrackEditorDocument : IEditorDocument { private TrackAuthoringGraph? graph; private TrackLayoutPackageV2GraphAncillaryState? ancillaryState; + private string? currentPackageJson; private string? cleanPackageJson; private bool explicitlyDirty; @@ -32,7 +34,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 +65,7 @@ private TrackEditorDocument( public TrackLayoutPackageV2Dto? Package => !CanSave ? null - : ExportPackage(graph!, ancillaryState!); + : ExportPackage(graph!, ancillaryState!, GraphCompileResult!); public TrackAuthoringCompilation? Compilation { get; private set; } @@ -137,10 +142,27 @@ 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 PreparedTrackGraphState CaptureGraphState() + { + if (graph is null) + { + throw new InvalidOperationException( + "This editor document does not have an editable authoring graph."); + } + + return PreparedTrackGraphState.FromPreparedData( + graph, + ancillaryState!, + GraphCompileResult, + currentPackageJson); + } + + internal PreparedTrackGraphState PrepareGraphState( + TrackAuthoringGraph candidateGraph, + TrackAuthoringGraphCompileResult? candidateCompilation) { ArgumentNullException.ThrowIfNull(candidateGraph); if (ancillaryState is null) @@ -149,7 +171,27 @@ 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 PreparedTrackGraphState.FromEvaluation( + candidateGraph, + ancillaryState, + graphCompileResult: null); + } + + if (candidateCompilation is null || + !candidateCompilation.Success || + candidateCompilation.Compilation is null) + { + throw new ArgumentException( + "A non-empty graph requires a successful compilation.", + nameof(candidateCompilation)); + } + + return PreparedTrackGraphState.FromEvaluation( + candidateGraph, + ancillaryState, + candidateCompilation); } public void ReplaceGraph(TrackAuthoringGraph candidateGraph) @@ -172,8 +214,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 +227,34 @@ public void ReplaceGraph(TrackAuthoringGraph candidateGraph) candidateCompilation.Diagnostics)); } - string candidatePackageJson = CapturePackageJson(candidateGraph); + ReplaceGraphState(PrepareGraphState(candidateGraph, candidateCompilation)); + } + + internal void ReplaceGraphState(PreparedTrackGraphState 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.CanonicalPackageJson, + StringComparison.Ordinal); + if (candidateState.GraphCompileResult is null) + { + CommitEmptyGraph(candidateState.Graph, candidateIsDirty); + return; + } + + CommitCompiledGraph( + candidateState.Graph, + candidateState.GraphCompileResult, + candidateState.CanonicalPackageJson!, + candidateIsDirty); } public void ReplacePackageJson(string json, bool markDirty = true) @@ -216,9 +280,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 +330,7 @@ public void MarkDirty() public void MarkClean() { explicitlyDirty = false; - cleanPackageJson = CanSave ? CapturePackageJson() : null; + cleanPackageJson = currentPackageJson; if (!IsDirty) { return; @@ -275,9 +343,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 +360,7 @@ private void CommitEmptyGraph( bool candidateIsDirty) { graph = candidateGraph; + currentPackageJson = null; GraphCompileResult = null; Compilation = null; TrackDocument = new TrackDocument(); @@ -299,19 +370,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/EditorWorkspace.cs b/Quantum.Editor.Avalonia/Services/EditorWorkspace.cs index f45da33..50162f2 100644 --- a/Quantum.Editor.Avalonia/Services/EditorWorkspace.cs +++ b/Quantum.Editor.Avalonia/Services/EditorWorkspace.cs @@ -1,5 +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; @@ -14,11 +16,23 @@ namespace Quantum.Editor.Avalonia.Services; -public sealed class EditorWorkspace +public sealed class EditorWorkspace : IDisposable, IAsyncDisposable { private readonly TrackDocumentFileService fileService; private readonly TrackSamplingService samplingService; + private readonly Func + evaluationCoordinatorFactory; + private readonly object coordinatorRetirementGate = new(); + private readonly List coordinatorRetirementTasks = new(); 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; @@ -40,7 +54,10 @@ public EditorWorkspace( UndoRedoService? undoRedo = null, ViewportService? viewport = null, TrackDocumentFileService? fileService = null, - TrackSamplingService? samplingService = null) + TrackSamplingService? samplingService = null, + Func? + evaluationCoordinatorFactory = null, + StraightLengthScrubSensitivity? straightLengthScrubSensitivity = null) { Documents = documents ?? new DocumentService(); Commands = commands ?? new CommandService(); @@ -49,6 +66,21 @@ public EditorWorkspace( Viewport = viewport ?? new ViewportService(); this.fileService = fileService ?? new TrackDocumentFileService(); this.samplingService = samplingService ?? new TrackSamplingService(); + this.evaluationCoordinatorFactory = evaluationCoordinatorFactory ?? + (session => new TrackAuthoringEvaluationCoordinator( + session, + AuthoringEvaluationSchedulerOptions.SerializedBackground)); + StraightLengthScrubSensitivity = straightLengthScrubSensitivity ?? + StraightLengthScrubSensitivity.Default; + + UndoRedo.DelegateHistory( + () => authoringSession?.CanUndo == true, + () => authoringSession?.CanRedo == true, + () => authoringSession?.UndoDescription, + () => authoringSession?.RedoDescription, + UndoAuthoringHistory, + RedoAuthoringHistory, + ClearAuthoringHistory); Documents.ActiveDocumentChanged += OnActiveDocumentChanged; Selection.SelectionChanged += OnSelectionChanged; @@ -72,8 +104,20 @@ public EditorWorkspace( public ViewportService Viewport { get; } + public StraightLengthScrubSensitivity StraightLengthScrubSensitivity { get; } + 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 @@ -105,6 +149,7 @@ public EditorWorkspace( public TrackEditorDocument NewDocument() { + CancelStraightLengthEdit(); TrackEditorDocument document = TrackEditorDocument.CreateEmpty( "Untitled Layout", "Untitled Layout"); @@ -115,6 +160,7 @@ public TrackEditorDocument NewDocument() public TrackEditorDocument OpenDocument(string filePath) { + CancelStraightLengthEdit(); TrackEditorDocument document = fileService.Open(filePath); ActivateDocument(document, $"Opened {document.FilePath}."); return document; @@ -122,6 +168,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); @@ -134,14 +181,24 @@ public bool ApplyGraphEdit( { ArgumentNullException.ThrowIfNull(edit); + if (IsInteractiveEditActive) + { + SetStatus("Source edits are disabled while a live edit is active."); + return false; + } + 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; } + TrackAuthoringSession session = authoringSession ?? + throw new InvalidOperationException("The active document has no authoring session."); + PreparedTrackGraphState beforeState = session.CommittedState.PreparedState; + TrackAuthoringGraph beforeGraph = beforeState.Graph; + TrackAuthoringGraph candidateGraph; try { @@ -171,9 +228,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,22 +242,35 @@ exception is InvalidOperationException || try { - if (beforeGraph.Nodes.Count != 0 && candidateGraph.Nodes.Count != 0) + PreparedTrackGraphState afterState = document.PrepareGraphState( + candidateGraph, + candidateCompilation); + if (beforeState.CanonicalPackageJson is not null && + afterState.CanonicalPackageJson is not null) { - string beforeJson = document.CapturePackageJson(); - string afterJson = document.CapturePackageJson(candidateGraph); - if (string.Equals(beforeJson, afterJson, StringComparison.Ordinal)) + if (string.Equals( + beforeState.CanonicalPackageJson, + afterState.CanonicalPackageJson, + StringComparison.Ordinal)) { SetStatus("No graph values changed."); return false; } } - UndoRedo.Execute(new TrackGraphSnapshotOperation( - description, + AuthoringCommitResult commit = session.CommitPreparedEdit(description, afterState); + if (!commit.Succeeded || commit.CommittedState is null) + { + SetStatus("Edit rejected: " + string.Join( + " ", + commit.Diagnostics.Select(diagnostic => diagnostic.Message))); + return false; + } + + ApplySessionStateToDocument( document, - beforeGraph, - candidateGraph)); + commit.CommittedState.PreparedState); + UndoRedo.NotifyStateChanged(); SetStatus(description + "."); return true; } @@ -407,6 +478,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(); @@ -487,6 +564,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) @@ -499,6 +582,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) @@ -509,14 +598,300 @@ public bool RedoLast() return result; } + private bool UndoAuthoringHistory() + { + TrackAuthoringSession? session = authoringSession; + TrackEditorDocument? document = ActiveDocument; + if (session is null || document is null || !session.Undo()) + { + return false; + } + + ApplySessionStateToDocument(document, session.CommittedState.PreparedState); + return true; + } + + private bool RedoAuthoringHistory() + { + TrackAuthoringSession? session = authoringSession; + TrackEditorDocument? document = ActiveDocument; + if (session is null || document is null || !session.Redo()) + { + return false; + } + + ApplySessionStateToDocument(document, session.CommittedState.PreparedState); + return true; + } + + private void ClearAuthoringHistory() + { + authoringSession?.ClearHistory(); + } + + private void ApplySessionStateToDocument( + TrackEditorDocument document, + PreparedTrackGraphState preparedState) + { + applyingSessionCommitToDocument = true; + try + { + document.ReplaceGraphState(preparedState); + } + finally + { + applyingSessionCommitToDocument = false; + } + } + public void SetStatus(string message) { statusMessage = string.IsNullOrWhiteSpace(message) ? "Ready" : 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."); + ApplySessionStateToDocument(document, afterState); + UndoRedo.NotifyStateChanged(); + } + + 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, + scheduler.SubmitToPresentSamples, + straightLengthFinalCommitWait == TimeSpan.Zero + ? Array.Empty() + : new[] { straightLengthFinalCommitWait }); + } + private void ActivateDocument(TrackEditorDocument document, string message) { + CancelStraightLengthEdit(); TrackEditorDocument? previousDocument = ActiveDocument; UndoRedo.Clear(); if (previousDocument != null) @@ -533,53 +908,91 @@ 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; } + RetireEvaluationCoordinator(); + 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 = evaluationCoordinatorFactory(authoringSession); + } } + 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(); } @@ -596,7 +1009,80 @@ 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(); + viewportSnapshot = TrackViewportSnapshot.Empty; + engineeringSnapshot = null; + stationCursor = null; + hoveredSectionIndex = -1; + projectedCompilation = null; + } + else + { + 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(); @@ -608,11 +1094,13 @@ private void RefreshDocumentProjection() } else { - graphNodes = BuildGraphNodes(document.GraphCompileResult.OrderedNodes); - outlinerNodes = BuildOutliner(document); - TrackAuthoringCompilation compilation = document.Compilation!; + 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++; @@ -623,9 +1111,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) @@ -635,24 +1131,145 @@ 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) + { + 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); + } + + public void Dispose() + { + BeginDispose(); + } + + public async ValueTask DisposeAsync() + { + BeginDispose(); + await WaitForLifecycleCompletionAsync().ConfigureAwait(false); + } + + public Task WaitForLifecycleCompletionAsync() + { + lock (coordinatorRetirementGate) + { + return coordinatorRetirementTasks.Count == 0 + ? Task.CompletedTask + : Task.WhenAll(coordinatorRetirementTasks.ToArray()); + } + } + + private void BeginDispose() + { + if (disposed) { return; } - if (GraphNodes.Any(node => - string.Equals(node.NodeId, selection.NodeId, StringComparison.Ordinal))) + disposed = true; + CancelStraightLengthEdit(); + Documents.ActiveDocumentChanged -= OnActiveDocumentChanged; + Selection.SelectionChanged -= OnSelectionChanged; + UndoRedo.StateChanged -= OnUndoRedoStateChanged; + if (observedDocument is not null) + { + observedDocument.Changed -= OnDocumentChanged; + } + + RetireEvaluationCoordinator(); + authoringSession = null; + observedDocument = null; + } + + private void RetireEvaluationCoordinator() + { + TrackAuthoringEvaluationCoordinator? coordinator = evaluationCoordinator; + evaluationCoordinator = null; + if (coordinator is null) { return; } - Select(EditorSelection.Track); + Task retirement = ObserveCoordinatorRetirementAsync(coordinator); + lock (coordinatorRetirementGate) + { + coordinatorRetirementTasks.Add(retirement); + } + } + + private static async Task ObserveCoordinatorRetirementAsync( + TrackAuthoringEvaluationCoordinator coordinator) + { + try + { + await coordinator.DisposeAsync().ConfigureAwait(false); + } + catch (Exception) + { + // Coordinator evaluation faults are converted into outcomes. This + // defensive observation prevents a teardown failure from becoming an + // unobserved task exception during process shutdown. + } } private static IReadOnlyList BuildGraphNodes( @@ -695,9 +1312,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++) { @@ -722,7 +1342,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]; @@ -731,7 +1351,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.Editor.Avalonia/Services/UndoRedo/TrackGraphSnapshotOperation.cs b/Quantum.Editor.Avalonia/Services/UndoRedo/TrackGraphSnapshotOperation.cs index 35365dc..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; @@ -9,8 +10,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 PreparedTrackGraphState? beforeState; + private readonly PreparedTrackGraphState? afterState; public TrackGraphSnapshotOperation( string description, @@ -26,9 +29,41 @@ public TrackGraphSnapshotOperation( this.afterGraph = afterGraph ?? throw new ArgumentNullException(nameof(afterGraph)); } + internal TrackGraphSnapshotOperation( + string description, + TrackEditorDocument document, + PreparedTrackGraphState beforeState, + PreparedTrackGraphState 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.Editor.Avalonia/Services/UndoRedo/UndoRedoService.cs b/Quantum.Editor.Avalonia/Services/UndoRedo/UndoRedoService.cs index 3054f47..0a4ebbe 100644 --- a/Quantum.Editor.Avalonia/Services/UndoRedo/UndoRedoService.cs +++ b/Quantum.Editor.Avalonia/Services/UndoRedo/UndoRedoService.cs @@ -4,20 +4,59 @@ public sealed class UndoRedoService : IUndoRedoService { private readonly Stack undoStack = new(); private readonly Stack redoStack = new(); + private Func? delegatedCanUndo; + private Func? delegatedCanRedo; + private Func? delegatedUndoDescription; + private Func? delegatedRedoDescription; + private Func? delegatedUndo; + private Func? delegatedRedo; + private Action? delegatedClear; public event EventHandler? StateChanged; - public bool CanUndo => undoStack.Count != 0; + public bool CanUndo => delegatedCanUndo?.Invoke() ?? undoStack.Count != 0; - public bool CanRedo => redoStack.Count != 0; + public bool CanRedo => delegatedCanRedo?.Invoke() ?? redoStack.Count != 0; - public string? UndoDescription => CanUndo ? undoStack.Peek().Description : null; + public string? UndoDescription => delegatedUndoDescription is not null + ? delegatedUndoDescription() + : CanUndo ? undoStack.Peek().Description : null; - public string? RedoDescription => CanRedo ? redoStack.Peek().Description : null; + public string? RedoDescription => delegatedRedoDescription is not null + ? delegatedRedoDescription() + : CanRedo ? redoStack.Peek().Description : null; + + internal void DelegateHistory( + Func canUndo, + Func canRedo, + Func undoDescription, + Func redoDescription, + Func undo, + Func redo, + Action clear) + { + delegatedCanUndo = canUndo ?? throw new ArgumentNullException(nameof(canUndo)); + delegatedCanRedo = canRedo ?? throw new ArgumentNullException(nameof(canRedo)); + delegatedUndoDescription = undoDescription ?? + throw new ArgumentNullException(nameof(undoDescription)); + delegatedRedoDescription = redoDescription ?? + throw new ArgumentNullException(nameof(redoDescription)); + delegatedUndo = undo ?? throw new ArgumentNullException(nameof(undo)); + delegatedRedo = redo ?? throw new ArgumentNullException(nameof(redo)); + delegatedClear = clear ?? throw new ArgumentNullException(nameof(clear)); + undoStack.Clear(); + redoStack.Clear(); + NotifyStateChanged(); + } public void Execute(IUndoableEditorOperation operation) { ArgumentNullException.ThrowIfNull(operation); + if (delegatedUndo is not null) + { + throw new InvalidOperationException( + "This undo service is a view of session-owned authoring history."); + } operation.Execute(); undoStack.Push(operation); @@ -27,6 +66,17 @@ public void Execute(IUndoableEditorOperation operation) public bool Undo() { + if (delegatedUndo is not null) + { + bool delegatedResult = delegatedUndo(); + if (delegatedResult) + { + NotifyStateChanged(); + } + + return delegatedResult; + } + if (!CanUndo) { return false; @@ -42,6 +92,17 @@ public bool Undo() public bool Redo() { + if (delegatedRedo is not null) + { + bool delegatedResult = delegatedRedo(); + if (delegatedResult) + { + NotifyStateChanged(); + } + + return delegatedResult; + } + if (!CanRedo) { return false; @@ -57,8 +118,17 @@ public bool Redo() public void Clear() { + if (delegatedClear is not null) + { + delegatedClear(); + NotifyStateChanged(); + return; + } + undoStack.Clear(); redoStack.Clear(); - StateChanged?.Invoke(this, EventArgs.Empty); + NotifyStateChanged(); } + + internal void NotifyStateChanged() => StateChanged?.Invoke(this, EventArgs.Empty); } 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.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/Application/TrackAuthoringEvaluationCoordinatorIntegrationTests.cs b/Quantum.Tests/Application/TrackAuthoringEvaluationCoordinatorIntegrationTests.cs new file mode 100644 index 0000000..8f5365a --- /dev/null +++ b/Quantum.Tests/Application/TrackAuthoringEvaluationCoordinatorIntegrationTests.cs @@ -0,0 +1,404 @@ +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 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() + { + 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.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 7fb0907..d3a7612 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 = @@ -112,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/Editor/M167CompilationPipelineMeasurementTests.cs b/Quantum.Tests/Editor/M167CompilationPipelineMeasurementTests.cs new file mode 100644 index 0000000..622e077 --- /dev/null +++ b/Quantum.Tests/Editor/M167CompilationPipelineMeasurementTests.cs @@ -0,0 +1,200 @@ +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 ApplyGraphEdit_CompilesOnceAndBuildsOneProjection() + { + 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(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 Save_DoesNotCompileOrRebuildProjection() + { + 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(0, authoring.GraphCompilerInvocationCount); + Assert.Equal(0, authoring.EngineeringSnapshotBuildCount); + Assert.Equal(0, viewport.ViewportProjectionBuildCount); + Assert.Equal(TimeSpan.Zero, authoring.GraphCompilerElapsed); + Assert.Equal(TimeSpan.Zero, authoring.EngineeringSnapshotBuildElapsed); + Assert.Equal(TimeSpan.Zero, viewport.ViewportProjectionBuildElapsed); + } + finally + { + File.Delete(filePath); + } + } + + [Fact] + public void Open_CompilesOnceAndBuildsOneProjection() + { + 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(1, authoring.GraphCompilerInvocationCount); + Assert.Equal(1, authoring.EngineeringSnapshotBuildCount); + Assert.Equal(1, viewport.ViewportProjectionBuildCount); + AssertPositiveTimings(authoring, viewport); + } + finally + { + File.Delete(filePath); + } + } + + [Fact] + 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( + "Edit sweeper radius", + candidate => TrackAuthoringGraphOperations.Replace( + candidate, + section.Id, + new ConstantCurvatureSectionDefinition( + section.Id, + section.Length, + radius: 30.0, + section.RollRadians)))); + TrackAuthoringCompilation afterCompilation = document.Compilation!; + + AssertHistoryMeasurement( + workspace.UndoLast, + expectedCompilerInvocations: 0); + Assert.Same(beforeCompilation, document.Compilation); + AssertHistoryMeasurement( + workspace.RedoLast, + 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( + 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); + Assert.Equal(TimeSpan.Zero, authoring.GraphCompilerElapsed); + Assert.True(authoring.EngineeringSnapshotBuildElapsed > TimeSpan.Zero); + Assert.True(viewport.ViewportProjectionBuildElapsed > TimeSpan.Zero); + } + + 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.Tests/Editor/M167HardeningFoundationTests.cs b/Quantum.Tests/Editor/M167HardeningFoundationTests.cs new file mode 100644 index 0000000..43a71ff --- /dev/null +++ b/Quantum.Tests/Editor/M167HardeningFoundationTests.cs @@ -0,0 +1,131 @@ +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 M167HardeningFoundationTests +{ + [Fact] + public void LatencyPercentilesUseDocumentedNearestRankCalculation() + { + TimeSpan[] samples = Enumerable.Range(1, 100) + .Select(value => TimeSpan.FromMilliseconds(value)) + .Reverse() + .ToArray(); + + LatencyPercentileSummary result = LatencyPercentiles.Calculate(samples); + + Assert.Equal(100, result.SampleCount); + Assert.Equal(TimeSpan.FromMilliseconds(50), result.P50); + Assert.Equal(TimeSpan.FromMilliseconds(95), result.P95); + Assert.Equal(TimeSpan.FromMilliseconds(99), result.P99); + Assert.Equal(0, LatencyPercentiles.Calculate(Array.Empty()).SampleCount); + Assert.Throws(() => + LatencyPercentiles.Calculate(new[] { TimeSpan.FromTicks(-1) })); + } + + [Fact] + public void SensitivityDefaultsAndConfigurationAreValidated() + { + StraightLengthScrubSensitivity defaults = + StraightLengthScrubSensitivity.Default; + Assert.Equal(0.1, defaults.Resolve(shift: false, control: false), 9); + Assert.Equal(0.01, defaults.Resolve(shift: true, control: false), 9); + Assert.Equal(1.0, defaults.Resolve(shift: false, control: true), 9); + Assert.Equal(0.01, defaults.Resolve(shift: true, control: true), 9); + + var configured = new StraightLengthScrubSensitivity(0.25, 0.025, 2.5); + Assert.Equal(0.25, configured.NormalMetersPerStep, 9); + Assert.Equal(0.025, configured.FineMetersPerStep, 9); + Assert.Equal(2.5, configured.CoarseMetersPerStep, 9); + Assert.Throws(() => + new StraightLengthScrubSensitivity(0.0, 0.01, 1.0)); + Assert.Throws(() => + new StraightLengthScrubSensitivity(0.1, double.NaN, 1.0)); + } + + [Fact] + public async Task SessionHistoryOrdersOneShotAndLiveEditsInOneStack() + { + await using var workspace = new EditorWorkspace(); + TrackEditorDocument document = CreateDocument("history"); + workspace.Documents.SetActiveDocument(document); + + Assert.True(ApplyLength(workspace, 31.0, "One-shot 31")); + Assert.True(workspace.BeginStraightLengthEdit("launch")); + AuthoringEvaluationOutcome outcome = await workspace + .SubmitStraightLengthEdit(33.0) + .Completion; + Assert.True(workspace.PublishStraightLengthOutcome(outcome)); + Assert.True((await workspace.CommitStraightLengthEditAsync())!.Succeeded); + Assert.True(ApplyLength(workspace, 35.0, "One-shot 35")); + + Assert.Equal(3, workspace.AuthoringSession!.History.UndoCount); + Assert.Equal("One-shot 35", workspace.UndoRedo.UndoDescription); + Assert.True(workspace.UndoLast()); + Assert.Equal(33.0, StraightLength(document), 9); + Assert.Equal("Edit straight length launch", workspace.UndoRedo.UndoDescription); + Assert.True(workspace.UndoLast()); + Assert.Equal(31.0, StraightLength(document), 9); + Assert.Equal("One-shot 31", workspace.UndoRedo.UndoDescription); + Assert.True(workspace.UndoLast()); + Assert.Equal(30.0, StraightLength(document), 9); + + Assert.True(workspace.RedoLast()); + Assert.Equal(31.0, StraightLength(document), 9); + Assert.True(workspace.RedoLast()); + Assert.Equal(33.0, StraightLength(document), 9); + Assert.True(workspace.RedoLast()); + Assert.Equal(35.0, StraightLength(document), 9); + } + + private static bool ApplyLength( + EditorWorkspace workspace, + double length, + string description) + { + return workspace.ApplyGraphEdit(description, graph => + { + StraightSectionDefinition straight = Assert.IsType( + graph.Nodes.Single(node => node.Id == "launch").Section); + return TrackAuthoringGraphOperations.Replace( + graph, + "launch", + new StraightSectionDefinition("launch", length, straight.RollRadians)); + }); + } + + internal static TrackEditorDocument CreateDocument(string suffix) + { + return TrackEditorDocument.Create( + new TrackLayoutPackageV2Dto + { + Metadata = new TrackLayoutMetadataV2Dto + { + Units = "meters", + SourceName = "M167.5 hardening fixture " + suffix, + LayoutId = "m167-5-" + suffix + }, + Sections = new[] + { + new TrackLayoutSectionV2Dto + { + Kind = TrackLayoutPackageV2Vocabulary.StraightSectionKind, + Id = "launch", + Length = 30.0 + } + } + }, + "M167.5 hardening fixture " + suffix); + } + + internal static double StraightLength(TrackEditorDocument document) + { + return Assert.IsType( + document.Graph!.Nodes.Single(node => node.Id == "launch").Section).Length; + } +} diff --git a/Quantum.Tests/Editor/M167InspectorHeadlessTests.cs b/Quantum.Tests/Editor/M167InspectorHeadlessTests.cs new file mode 100644 index 0000000..e6468fa --- /dev/null +++ b/Quantum.Tests/Editor/M167InspectorHeadlessTests.cs @@ -0,0 +1,598 @@ +using Avalonia; +using Avalonia.Automation; +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