diff --git a/src/DynamoCore/Graph/Workspaces/Locking/GraphLockFile.cs b/src/DynamoCore/Graph/Workspaces/Locking/GraphLockFile.cs index f642fb58405..0af0a8d3241 100644 --- a/src/DynamoCore/Graph/Workspaces/Locking/GraphLockFile.cs +++ b/src/DynamoCore/Graph/Workspaces/Locking/GraphLockFile.cs @@ -8,6 +8,17 @@ namespace Dynamo.Graph.Workspaces.Locking { + /// + /// Describes the outcome of a call. + /// + internal enum GraphLockReadResult + { + NotFound, + Ok, + Corrupt, + TransientFailure + } + /// /// Provides file-system operations for graph lock sidecar files. /// @@ -24,6 +35,12 @@ internal static class GraphLockFile TypeNameHandling = TypeNameHandling.None }); + private static bool IsValidLockInfo(GraphLockInfo info) => + info != null + && info.SessionId != Guid.Empty + && !string.IsNullOrEmpty(info.GraphPath) + && info.ProcessId > 0; + /// /// Gets the sidecar lock path for a graph file. /// @@ -65,10 +82,13 @@ internal static bool TryCreateNewLockFile(string sidecarPath, GraphLockInfo info /// The sidecar lock file path. /// The parsed lock metadata when reading succeeds. /// True if metadata was read; otherwise false. - internal static bool TryRead(string sidecarPath, out GraphLockInfo info) + internal static GraphLockReadResult TryRead(string sidecarPath, out GraphLockInfo info) { info = null; + if (!File.Exists(sidecarPath)) + return GraphLockReadResult.NotFound; + const int maxAttempts = 2; for (var attempt = 0; attempt < maxAttempts; attempt++) { @@ -81,29 +101,25 @@ internal static bool TryRead(string sidecarPath, out GraphLockInfo info) info = Serializer.Deserialize(jsonReader); } - return info != null; + return IsValidLockInfo(info) ? GraphLockReadResult.Ok : GraphLockReadResult.Corrupt; } - catch (Exception ex) when (ex is IOException || ex is JsonException) + catch (Exception ex) when (ex is JsonException) { - if (attempt == maxAttempts - 1) - { - return false; - } - - // Wait for 50 milliseconds before retrying - Thread.Sleep(50); + return GraphLockReadResult.Corrupt; } - catch (UnauthorizedAccessException) + catch (Exception ex) when (ex is IOException) { - return false; + if (attempt == maxAttempts - 1) + return GraphLockReadResult.TransientFailure; + Thread.Sleep(50); } - catch (SecurityException) + catch (Exception ex) when (ex is UnauthorizedAccessException || ex is SecurityException) { - return false; + return GraphLockReadResult.TransientFailure; } } - return false; + return GraphLockReadResult.TransientFailure; } /// diff --git a/src/DynamoCore/Graph/Workspaces/Locking/GraphLockManager.cs b/src/DynamoCore/Graph/Workspaces/Locking/GraphLockManager.cs index 7096210b41d..e543275ddd5 100644 --- a/src/DynamoCore/Graph/Workspaces/Locking/GraphLockManager.cs +++ b/src/DynamoCore/Graph/Workspaces/Locking/GraphLockManager.cs @@ -15,8 +15,9 @@ namespace Dynamo.Graph.Workspaces.Locking /// internal sealed class GraphLockManager : IDisposable { - internal const int DefaultHeartbeatMilliseconds = 30000; + internal const int DefaultHeartbeatMilliseconds = 15000; private const int StaleFactor = 3; + private const int MaxConsecutiveReadFailures = 3; private readonly DynamoModel dynamoModel; private readonly StringComparer pathComparer; @@ -37,6 +38,7 @@ private sealed class OwnedLock internal string SidecarPath { get; set; } internal GraphLockInfo Info { get; set; } internal WorkspaceModel Workspace { get; set; } + internal int ConsecutiveReadFailures { get; set; } } /// @@ -192,22 +194,28 @@ private GraphLockAcquireResult AcquireLockInternal(string normalizedPath, bool a } // A lock file already exists: read it to find out who owns it - GraphLockInfo existingLock; - var readable = GraphLockFile.TryRead(sidecarPath, out existingLock); + var readResult = GraphLockFile.TryRead(sidecarPath, out var existingLock); - var lockResult = TryAcquireReadableLock(normalizedPath, sidecarPath, info, workspace, readable, existingLock); + if (readResult == GraphLockReadResult.NotFound) + { + // The sidecar was deleted between TryCreateNewLockFile failing and TryRead. + // Retry the loop — TryCreateNewLockFile will succeed on the next attempt. + continue; + } + + var lockResult = TryAcquireReadableLock(normalizedPath, sidecarPath, info, workspace, readResult, existingLock); if (lockResult != null) { return lockResult; } - // A live (or currently unreadable) lock owns the path. + // ExistingLock is Ok and live — a real conflict with another session. return ResolveLockConflict(normalizedPath, allowPromptUI, workspace, existingLock); } catch (Exception ex) when (ex is UnauthorizedAccessException || ex is SecurityException || ex is IOException) { dynamoModel.Logger?.Log("GraphLock unavailable: " + ex.Message); - } + } } return GraphLockAcquireResult.Unavailable(normalizedPath); @@ -218,15 +226,26 @@ private GraphLockAcquireResult TryAcquireReadableLock( string sidecarPath, GraphLockInfo info, WorkspaceModel workspace, - bool readable, + GraphLockReadResult readResult, GraphLockInfo existingLock) { - if (!readable) + if (readResult == GraphLockReadResult.Corrupt) { - return null; + // Definitively unreadable — not evidence of a live owner. + // Safe to overwrite and take ownership. + GraphLockFile.WriteHeartbeat(sidecarPath, info); + RegisterOwnedLock(normalizedPath, sidecarPath, info, workspace); + return GraphLockAcquireResult.Acquired(normalizedPath); + } + + if (readResult == GraphLockReadResult.TransientFailure) + { + // Could not read due to IO/permissions — a live owner may exist. + // Do not steal the lock. Redirect to copy to be safe. + dynamoModel.Logger?.Log( "GraphLock: could not read sidecar due to transient failure, redirecting to copy: " + sidecarPath); + return GraphLockAcquireResult.Unavailable(normalizedPath); } - // It is our own lock (same machine + process): reuse it if (IsOwnedByThisSession(existingLock)) { RegisterOwnedLock(normalizedPath, sidecarPath, existingLock, workspace); @@ -234,10 +253,7 @@ private GraphLockAcquireResult TryAcquireReadableLock( } // The lock is expired (no recent heartbeat) or owned by a process on this machine - // that is no longer running. In these cases the previous owner is gone, so we - // silently take the lock over. A present but unreadable lock is not reclaimed here, - // it is treated as a real conflict below, so that a transient read failure cannot - // make us steal a lock that a live instance still owns. + // that is no longer running. Silently take ownership. if (IsStale(existingLock) || IsDeadLocalProcess(existingLock)) { GraphLockFile.WriteHeartbeat(sidecarPath, info); @@ -297,13 +313,13 @@ private GraphLockAcquireResult CreateAndOpenCopy( string sourcePath, string save else { GraphLockInfo saveAsLock; - var readable = GraphLockFile.TryRead(sidecarPath, out saveAsLock); - if (readable && IsOwnedByThisSession(saveAsLock)) + var saveAsReadResult = GraphLockFile.TryRead(sidecarPath, out saveAsLock); + if (saveAsReadResult == GraphLockReadResult.Ok && IsOwnedByThisSession(saveAsLock)) { info = saveAsLock; ownsSaveAsLock = true; } - else if (!readable || IsStale(saveAsLock) || IsDeadLocalProcess(saveAsLock)) + else if (saveAsReadResult == GraphLockReadResult.Corrupt || IsStale(saveAsLock) || IsDeadLocalProcess(saveAsLock)) { GraphLockFile.WriteHeartbeat(sidecarPath, info); ownsSaveAsLock = true; @@ -341,7 +357,7 @@ private void RegisterOwnedLock(string normalizedPath, string sidecarPath, GraphL }; } - private void RefreshHeartbeats(object state) + internal void RefreshHeartbeats(object state = null) { foreach (var pair in locks.ToList()) { @@ -349,9 +365,34 @@ private void RefreshHeartbeats(object state) try { GraphLockInfo current; - if (!GraphLockFile.TryRead(owned.SidecarPath, out current) || - current.SessionId != owned.Info.SessionId) + if (GraphLockFile.TryRead(owned.SidecarPath, out current) != GraphLockReadResult.Ok) + { + // Transient IO failure (antivirus, NAS hiccup) — don't drop the lock immediately. + // Only abandon after MaxConsecutiveReadFailures consecutive misses. + owned.ConsecutiveReadFailures++; + + if (owned.ConsecutiveReadFailures >= MaxConsecutiveReadFailures) + { + dynamoModel.Logger?.Log( + $"GraphLock heartbeat read failed {MaxConsecutiveReadFailures} consecutive times, " + + $"dropping lock: {owned.SidecarPath}"); + locks.TryRemove(pair.Key, out _); + } + else + { + dynamoModel.Logger?.Log( + $"GraphLock heartbeat read failed (attempt {owned.ConsecutiveReadFailures}), " + + $"will retry: {owned.SidecarPath}"); + } + continue; + } + + // Successful read — reset the failure counter + owned.ConsecutiveReadFailures = 0; + + if (current.SessionId != owned.Info.SessionId) { + // Lock was genuinely stolen by another instance — stop tracking locks.TryRemove(pair.Key, out _); continue; } @@ -371,7 +412,7 @@ private void ReleaseOwnedLock(string normalizedPath, OwnedLock owned) try { GraphLockInfo current; - if (GraphLockFile.TryRead(owned.SidecarPath, out current) && + if (GraphLockFile.TryRead(owned.SidecarPath, out current) == GraphLockReadResult.Ok && current.SessionId == owned.Info.SessionId) { GraphLockFile.TryDelete(owned.SidecarPath); diff --git a/src/DynamoCoreWpf/Properties/Resources.Designer.cs b/src/DynamoCoreWpf/Properties/Resources.Designer.cs index 9fe93cdcd4a..b94a6658674 100644 --- a/src/DynamoCoreWpf/Properties/Resources.Designer.cs +++ b/src/DynamoCoreWpf/Properties/Resources.Designer.cs @@ -519,6 +519,25 @@ public static string ConfigureADPButtonText { } } + /// + /// Looks up a localized string similar to {0} already exists. + ///Do you want to replace it?. + /// + public static string ConfirmReplaceFileMessage { + get { + return ResourceManager.GetString("ConfirmReplaceFileMessage", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Confirm Save As. + /// + public static string ConfirmReplaceFileTitle { + get { + return ResourceManager.GetString("ConfirmReplaceFileTitle", resourceCulture); + } + } + /// /// Looks up a localized string similar to Break Connection. /// @@ -3771,6 +3790,16 @@ public static string GraphLockSaveAsButton { } } + /// + /// Looks up a localized string similar to This file is currently open in another instance of Dynamo. + ///To avoid data loss or corruption, please choose a different file name.. + /// + public static string GraphLockSaveAsSameFileMessage { + get { + return ResourceManager.GetString("GraphLockSaveAsSameFileMessage", resourceCulture); + } + } + /// /// Looks up a localized string similar to Add Group to This Group. /// diff --git a/src/DynamoCoreWpf/Properties/Resources.en-US.resx b/src/DynamoCoreWpf/Properties/Resources.en-US.resx index 6ab8ca5b259..e24dca8cd4b 100644 --- a/src/DynamoCoreWpf/Properties/Resources.en-US.resx +++ b/src/DynamoCoreWpf/Properties/Resources.en-US.resx @@ -4313,4 +4313,15 @@ To avoid data loss or corruption, please choose one of the following actions: Save As + + This file is currently open in another instance of Dynamo. +To avoid data loss or corruption, please choose a different file name. + + + {0} already exists. +Do you want to replace it? + + + Confirm Save As + diff --git a/src/DynamoCoreWpf/Properties/Resources.resx b/src/DynamoCoreWpf/Properties/Resources.resx index bbd5beea296..54957d694c4 100644 --- a/src/DynamoCoreWpf/Properties/Resources.resx +++ b/src/DynamoCoreWpf/Properties/Resources.resx @@ -4300,4 +4300,15 @@ To avoid data loss or corruption, please choose one of the following actions: Save As + + This file is currently open in another instance of Dynamo. +To avoid data loss or corruption, please choose a different file name. + + + {0} already exists. +Do you want to replace it? + + + Confirm Save As + diff --git a/src/DynamoCoreWpf/PublicAPI.Unshipped.txt b/src/DynamoCoreWpf/PublicAPI.Unshipped.txt index 7545e42e137..5b7601e4a62 100644 --- a/src/DynamoCoreWpf/PublicAPI.Unshipped.txt +++ b/src/DynamoCoreWpf/PublicAPI.Unshipped.txt @@ -33,6 +33,9 @@ Dynamo.ViewModels.PreferencesViewModel.AutoSyncDocumentBrowserIsChecked.get -> b Dynamo.ViewModels.PreferencesViewModel.AutoSyncDocumentBrowserIsChecked.set -> void Dynamo.ViewModels.PreferencesViewModel.ShowPythonAutoMigrationNotificationIsChecked.get -> bool Dynamo.ViewModels.PreferencesViewModel.ShowPythonAutoMigrationNotificationIsChecked.set -> void +Dynamo.Wpf.UI.CustomSaveFileDialog.FileOk -> event System.ComponentModel.CancelEventHandler +Dynamo.Wpf.UI.CustomSaveFileDialog.OverwritePrompt.get -> bool +Dynamo.Wpf.UI.CustomSaveFileDialog.OverwritePrompt.set -> void Dynamo.Wpf.UI.ToastManager Dynamo.Wpf.UI.ToastManager.CloseRealTimeInfoWindow() -> void Dynamo.Wpf.UI.ToastManager.CreateRealTimeInfoWindow(string content, bool stayOpen = false, string headerText = "", string hyperlinkText = "", System.Uri hyperlinkUri = null, System.Uri fileLinkUri = null) -> void diff --git a/src/DynamoCoreWpf/Services/WpfGraphLockUserPrompt.cs b/src/DynamoCoreWpf/Services/WpfGraphLockUserPrompt.cs index 47d5e9e259d..e0119efc6af 100644 --- a/src/DynamoCoreWpf/Services/WpfGraphLockUserPrompt.cs +++ b/src/DynamoCoreWpf/Services/WpfGraphLockUserPrompt.cs @@ -5,6 +5,7 @@ using System; using System.IO; using System.Windows; +using System.Windows.Forms; namespace Dynamo.Wpf.Services { @@ -15,16 +16,26 @@ internal sealed class WpfGraphLockUserPrompt : IGraphLockUserPrompt { private readonly Func ownerProvider; private readonly string productNameProvider; + private readonly Func dialogFactory; + private readonly Func showMessageBox; /// /// Initializes a WPF graph-lock prompt. /// /// Provides the owner window when a prompt is shown. - /// Provides the product name for save-dialog filters. - internal WpfGraphLockUserPrompt(Func ownerProvider, string productNameProvider) + /// The product name for save-dialog filters. + /// Optional factory for the save dialog; defaults to . + /// Optional message box function used for conflict and overwrite prompts. + internal WpfGraphLockUserPrompt( + Func ownerProvider, + string productName, + Func dialogFactory = null, + Func showMessageBox = null) { this.ownerProvider = ownerProvider; - this.productNameProvider = productNameProvider; + this.productNameProvider = productName; + this.dialogFactory = dialogFactory ?? (() => new CustomSaveFileDialog()); + this.showMessageBox = showMessageBox ?? System.Windows.Forms.MessageBox.Show; } /// @@ -58,7 +69,7 @@ public GraphLockUserResponse AskUser(string graphPath, GraphLockInfo existingLoc } // Shows a Save As dialog for the copy path, matching the graph file extension - private string ShowSaveAsDialog(string graphPath) + internal string ShowSaveAsDialog(string graphPath) { var extension = Path.GetExtension(graphPath); var directory = Path.GetDirectoryName(graphPath); @@ -69,19 +80,52 @@ private string ShowSaveAsDialog(string graphPath) ? string.Format(Resources.FileDialogDynamoCustomNode, productName, "*.dyf") : string.Format(Resources.FileDialogDynamoWorkspace, productName, "*.dyn"); - var dialog = new CustomSaveFileDialog - { - AddExtension = true, - DefaultExt = defaultExt, - Filter = filter, - FileName = Path.GetFileName(graphPath), - }; + var normalizedSourcePath = Path.GetFullPath(graphPath); + + var dialog = dialogFactory(); + dialog.AddExtension = true; + dialog.DefaultExt = defaultExt; + dialog.Filter = filter; + dialog.FileName = Path.GetFileName(graphPath); + dialog.OverwritePrompt = false; if (Directory.Exists(directory)) { dialog.InitialDirectory = directory; } + dialog.FileOk += (sender, e) => + { + var chosenPath = Path.GetFullPath(dialog.FileName); + + // Block saving over the locked source file + if (string.Equals(chosenPath, normalizedSourcePath, StringComparison.OrdinalIgnoreCase)) + { + showMessageBox( + Resources.GraphLockSaveAsSameFileMessage, + Resources.GraphLockFileAlreadyOpenTitle, + MessageBoxButtons.OK, + MessageBoxIcon.Warning); + e.Cancel = true; + return; + } + + // OverwritePrompt is false so we must replicate the OS overwrite warning + // for any other existing file the user may have chosen. + if (File.Exists(chosenPath)) + { + var confirm = showMessageBox( + string.Format(Resources.ConfirmReplaceFileMessage, Path.GetFileName(chosenPath)), + Resources.ConfirmReplaceFileTitle, + MessageBoxButtons.YesNo, + MessageBoxIcon.Warning); + if (confirm != DialogResult.Yes) + { + e.Cancel = true; + } + } + }; + return dialog.ShowDialog() == true ? dialog.FileName : null; } } diff --git a/src/DynamoCoreWpf/UI/CustomSaveFileDialog.cs b/src/DynamoCoreWpf/UI/CustomSaveFileDialog.cs index 99ebc55b4c8..7dc5a56da91 100644 --- a/src/DynamoCoreWpf/UI/CustomSaveFileDialog.cs +++ b/src/DynamoCoreWpf/UI/CustomSaveFileDialog.cs @@ -1,3 +1,4 @@ +using System.ComponentModel; using System.Windows.Forms; namespace Dynamo.Wpf.UI @@ -12,7 +13,9 @@ internal interface IFileSaver string FileName { get; set; } public bool AddExtension { get; set; } public string InitialDirectory { get; set; } - bool? ShowDialog(); // Returns true if OK, false if Cancel, null for other cases + event CancelEventHandler FileOk; + bool OverwritePrompt { get; set; } + bool? ShowDialog(); // Returns true if OK, false if Cancel, null for other cases } /// @@ -52,6 +55,18 @@ public string InitialDirectory set => _dialog.InitialDirectory = value; } + public event CancelEventHandler FileOk + { + add => _dialog.FileOk += value; + remove => _dialog.FileOk -= value; + } + + public bool OverwritePrompt + { + get => _dialog.OverwritePrompt; + set => _dialog.OverwritePrompt = value; + } + public bool? ShowDialog() { DialogResult result = _dialog.ShowDialog(); diff --git a/test/DynamoCoreTests/Graph/Workspaces/GraphLockManagerTests.cs b/test/DynamoCoreTests/Graph/Workspaces/GraphLockManagerTests.cs index 274105e0684..4e8b64f02b9 100644 --- a/test/DynamoCoreTests/Graph/Workspaces/GraphLockManagerTests.cs +++ b/test/DynamoCoreTests/Graph/Workspaces/GraphLockManagerTests.cs @@ -181,7 +181,8 @@ private static GraphLockInfo CreateForeignLockInfo(string graphPath, DateTime la private static GraphLockInfo ReadLockInfo(string lockPath) { - Assert.IsTrue(GraphLockFile.TryRead(lockPath, out var lockInfo)); + Assert.AreEqual(GraphLockReadResult.Ok, GraphLockFile.TryRead(lockPath, out var lockInfo), + "Expected lock file to be readable at: " + lockPath); return lockInfo; } @@ -208,5 +209,111 @@ public GraphLockUserResponse AskUser(string graphPath, GraphLockInfo existingLoc return response; } } + + [Test] + [Category("UnitTests")] + public void WhenLockFileIsCorruptThenAcquireSucceedsWithoutPrompt() + { + // Arrange - write a sidecar that cannot be parsed as JSON so TryRead returns false + var graphPath = CreateGraphFile("corrupt-lock.dyn"); + var lockPath = GraphLockFile.GetLockFilePath(graphPath); + File.WriteAllText(lockPath, "{ this is not valid json {{{{"); + var prompt = new TestGraphLockUserPrompt(GraphLockUserResponse.Cancel()); + + using (var manager = CreateManager(prompt)) + { + // Act + var result = manager.AcquireLock(graphPath, true); + + // Assert - corrupt sidecar is treated as stale, not shown to the user as a conflict + Assert.AreEqual(GraphLockOutcome.Opened, result.Conflict); + Assert.IsTrue(result.ShouldOpen); + Assert.AreEqual(0, prompt.CallCount, "Prompt must not fire for a corrupt lock file"); + + manager.CompleteOpen(graphPath, true); + manager.Release(graphPath); + } + } + + [Test] + [Category("UnitTests")] + public void WhenLockFileIsCorruptThenOwnershipIsTransferredToCurrentSession() + { + // Arrange + var graphPath = CreateGraphFile("corrupt-lock-ownership.dyn"); + var lockPath = GraphLockFile.GetLockFilePath(graphPath); + File.WriteAllText(lockPath, "not valid json at all"); + + using (var manager = CreateManager()) + { + // Act + var result = manager.AcquireLock(graphPath, true); + manager.CompleteOpen(graphPath, true); + + // Assert - sidecar is overwritten with a valid lock owned by this session + Assert.AreEqual(GraphLockOutcome.Opened, result.Conflict); + var lockInfo = ReadLockInfo(lockPath); + Assert.AreEqual(Path.GetFullPath(graphPath), lockInfo.GraphPath); + Assert.That(lockInfo.SessionId, Is.Not.EqualTo(Guid.Empty)); + Assert.That(lockInfo.LastHeartbeatUtc, Is.GreaterThan(DateTime.UtcNow.AddSeconds(-5))); + + manager.Release(graphPath); + } + } + + [Test] + [Category("UnitTests")] + public void WhenHeartbeatDetectsSessionMismatchThenForeignLockIsNotDeleted() + { + // Arrange - acquire, then simulate another instance overwriting the sidecar + var graphPath = CreateGraphFile("heartbeat-mismatch.dyn"); + var lockPath = GraphLockFile.GetLockFilePath(graphPath); + var stolenLock = CreateForeignLockInfo(graphPath, DateTime.UtcNow); + + using (var manager = CreateManager(heartbeatMilliseconds: int.MaxValue)) + { + var result = manager.AcquireLock(graphPath, true); + manager.CompleteOpen(graphPath, true); + Assert.AreEqual(GraphLockOutcome.Opened, result.Conflict); + + // Simulate another instance overwriting our sidecar + GraphLockFile.WriteHeartbeat(lockPath, stolenLock); + + // Trigger one heartbeat cycle directly + manager.RefreshHeartbeats(); + } + + // Assert - manager must not delete a sidecar it no longer owns + Assert.IsTrue(File.Exists(lockPath)); + Assert.AreEqual(GraphLockReadResult.Ok, GraphLockFile.TryRead(lockPath, out var remaining), + "Expected stolen lock sidecar to still be readable after session mismatch."); + Assert.AreEqual(stolenLock.SessionId, remaining.SessionId); + } + + [Test] + [Category("UnitTests")] + public void WhenLiveLockExistsOnRemoteMachineThenPromptIsCalledWithNonNullLockInfo() + { + // Arrange - verifies that ResolveLockConflict only fires with a fully readable lock, + // guarding against the null-prompt bug from a corrupt sidecar + var graphPath = CreateGraphFile("live-remote-lock.dyn"); + var lockPath = GraphLockFile.GetLockFilePath(graphPath); + var existingLock = CreateForeignLockInfo(graphPath, DateTime.UtcNow); + Assert.IsTrue(GraphLockFile.TryCreateNewLockFile(lockPath, existingLock)); + + var prompt = new TestGraphLockUserPrompt(GraphLockUserResponse.Cancel()); + + using (var manager = CreateManager(prompt)) + { + // Act + manager.AcquireLock(graphPath, true); + manager.CompleteOpen(graphPath, false); + } + + // Assert - prompt received a non-null, readable lock (not null from a corrupt file) + Assert.AreEqual(1, prompt.CallCount); + Assert.IsNotNull(prompt.ExistingLock, "Prompt must never receive a null ExistingLock"); + Assert.AreEqual(existingLock.SessionId, prompt.ExistingLock.SessionId); + } } } diff --git a/test/DynamoCoreWpfTests/WpfGraphLockUserPromptTests.cs b/test/DynamoCoreWpfTests/WpfGraphLockUserPromptTests.cs new file mode 100644 index 00000000000..1746fa1636a --- /dev/null +++ b/test/DynamoCoreWpfTests/WpfGraphLockUserPromptTests.cs @@ -0,0 +1,179 @@ +using Dynamo.Graph.Workspaces.Locking; +using Dynamo.Wpf.Services; +using Dynamo.Wpf.UI; +using NUnit.Framework; +using System; +using System.IO; +using System.Windows.Forms; + +namespace DynamoCoreWpfTests +{ + [TestFixture] + [Category("UnitTests")] + public class WpfGraphLockUserPromptTests + { + private string tempDir; + + [SetUp] + public void SetUp() + { + tempDir = Path.Combine(Path.GetTempPath(), System.Guid.NewGuid().ToString()); + Directory.CreateDirectory(tempDir); + } + + [TearDown] + public void TearDown() + { + if (Directory.Exists(tempDir)) + Directory.Delete(tempDir, recursive: true); + } + + private string FilePath(string name) => Path.Combine(tempDir, name); + + private WpfGraphLockUserPrompt CreatePrompt( + IFileSaver dialog, + Func msgBox = null) + { + return new WpfGraphLockUserPrompt( + ownerProvider: () => null, + productName: "Dynamo", + dialogFactory: () => dialog, + showMessageBox: msgBox ?? ((_, __, ___, ____) => DialogResult.OK)); + } + + [Test] + public void WhenUserPicksLockedSourceFileThenSaveIsBlocked() + { + // Arrange + var graphPath = FilePath("locked.dyn"); + File.WriteAllText(graphPath, "graph"); + var dialog = new MockFileSaver { FileToSelect = graphPath }; + var prompt = CreatePrompt(dialog); + + // Act + var result = prompt.ShowSaveAsDialog(graphPath); + + // Assert + Assert.IsNull(result, "Picking the locked source file must cancel the save"); + } + + [Test] + public void WhenUserPicksLockedSourceFileWithDifferentCasingThenSaveIsBlocked() + { + // Arrange - validates the Path.GetFullPath normalisation fix + var graphPath = FilePath("MyGraph.dyn"); + File.WriteAllText(graphPath, "graph"); + var dialog = new MockFileSaver { FileToSelect = graphPath.ToUpperInvariant() }; + var prompt = CreatePrompt(dialog); + + // Act + var result = prompt.ShowSaveAsDialog(graphPath); + + // Assert + Assert.IsNull(result, "Case-insensitive path match must still block the save"); + } + + [Test] + public void WhenUserPicksNewNonExistingFileThenSaveProceeds() + { + // Arrange + var graphPath = FilePath("source.dyn"); + var savePath = FilePath("new-copy.dyn"); + File.WriteAllText(graphPath, "graph"); + // savePath intentionally does not exist + var dialog = new MockFileSaver { FileToSelect = savePath }; + var prompt = CreatePrompt(dialog); + + // Act + var result = prompt.ShowSaveAsDialog(graphPath); + + // Assert + Assert.AreEqual(savePath, result, "Picking a new path should proceed without any prompt"); + } + + [Test] + public void WhenUserPicksExistingFileAndConfirmsThenSaveProceeds() + { + // Arrange + var graphPath = FilePath("source.dyn"); + var existingPath = FilePath("existing.dyn"); + File.WriteAllText(graphPath, "graph"); + File.WriteAllText(existingPath, "other graph"); + + var dialog = new MockFileSaver { FileToSelect = existingPath }; + var prompt = CreatePrompt(dialog, (_, __, ___, ____) => DialogResult.Yes); + + // Act + var result = prompt.ShowSaveAsDialog(graphPath); + + // Assert + Assert.AreEqual(existingPath, result, "Confirming overwrite should allow the save"); + } + + [Test] + public void WhenUserPicksExistingFileAndDeclinesOverwriteThenSaveIsBlocked() + { + // Arrange + var graphPath = FilePath("source.dyn"); + var existingPath = FilePath("existing.dyn"); + File.WriteAllText(graphPath, "graph"); + File.WriteAllText(existingPath, "other graph"); + + var dialog = new MockFileSaver { FileToSelect = existingPath }; + var prompt = CreatePrompt(dialog, (_, __, ___, ____) => DialogResult.No); + + // Act + var result = prompt.ShowSaveAsDialog(graphPath); + + // Assert + Assert.IsNull(result, "Declining the overwrite confirmation must cancel the save"); + } + + [Test] + public void WhenUserCancelsDialogThenSaveIsBlocked() + { + // Arrange - user presses Cancel without picking any file + var graphPath = FilePath("source.dyn"); + File.WriteAllText(graphPath, "graph"); + var dialog = new MockFileSaver { SimulatedShowDialogResult = false }; + var prompt = CreatePrompt(dialog); + + // Act + var result = prompt.ShowSaveAsDialog(graphPath); + + // Assert + Assert.IsNull(result, "Cancelling the dialog should return null"); + } + + private sealed class MockFileSaver : IFileSaver + { + public string Filter { get; set; } + public string DefaultExt { get; set; } + public string FileName { get; set; } + public bool AddExtension { get; set; } + public string InitialDirectory { get; set; } + public bool OverwritePrompt { get; set; } + + internal string FileToSelect { get; set; } + internal bool? SimulatedShowDialogResult { get; set; } = true; + + private System.ComponentModel.CancelEventHandler fileOkHandler; + public event System.ComponentModel.CancelEventHandler FileOk + { + add => fileOkHandler += value; + remove => fileOkHandler -= value; + } + + public bool? ShowDialog() + { + if (FileToSelect != null) + FileName = FileToSelect; + + var args = new System.ComponentModel.CancelEventArgs(); + fileOkHandler?.Invoke(this, args); + + return args.Cancel ? false : SimulatedShowDialogResult; + } + } + } +}