diff --git a/src/SharpSync/Core/ISyncEngine.cs b/src/SharpSync/Core/ISyncEngine.cs index cd544c4..fcfc5a4 100644 --- a/src/SharpSync/Core/ISyncEngine.cs +++ b/src/SharpSync/Core/ISyncEngine.cs @@ -29,6 +29,19 @@ public interface ISyncEngine: IDisposable { /// Task PreviewSyncAsync(SyncOptions? options = null, CancellationToken cancellationToken = default); + /// + /// Gets a detailed plan of synchronization actions that will be performed + /// + /// Optional synchronization options + /// Cancellation token to cancel the operation + /// A detailed sync plan with all planned actions + /// + /// This method performs change detection and returns a detailed plan of what will happen during synchronization, + /// without actually modifying any files. Desktop clients can use this to show users a detailed preview with + /// file-by-file information, sizes, and action types before synchronization begins. + /// + Task GetSyncPlanAsync(SyncOptions? options = null, CancellationToken cancellationToken = default); + /// /// Gets the current sync statistics /// diff --git a/src/SharpSync/Core/SyncActionType.cs b/src/SharpSync/Core/SyncActionType.cs new file mode 100644 index 0000000..acacd02 --- /dev/null +++ b/src/SharpSync/Core/SyncActionType.cs @@ -0,0 +1,35 @@ +namespace Oire.SharpSync.Core; + +/// +/// Defines the types of synchronization actions that can be performed on files and directories. +/// +/// +/// These action types are used in sync plans to indicate what operation will be performed on each item. +/// Desktop clients can use this information to show users detailed previews before synchronization. +/// +public enum SyncActionType { + /// + /// Download a file or directory from remote storage to local storage + /// + Download, + + /// + /// Upload a file or directory from local storage to remote storage + /// + Upload, + + /// + /// Delete a file or directory from local storage + /// + DeleteLocal, + + /// + /// Delete a file or directory from remote storage + /// + DeleteRemote, + + /// + /// A conflict exists and requires resolution + /// + Conflict +} diff --git a/src/SharpSync/Core/SyncPlan.cs b/src/SharpSync/Core/SyncPlan.cs new file mode 100644 index 0000000..2e42a06 --- /dev/null +++ b/src/SharpSync/Core/SyncPlan.cs @@ -0,0 +1,138 @@ +namespace Oire.SharpSync.Core; + +/// +/// Represents a detailed plan of synchronization actions that will be performed. +/// +/// +/// This class provides desktop clients with comprehensive information about what will happen +/// during synchronization, allowing users to review and understand changes before they are applied. +/// The plan groups actions by type for easier presentation in UI. +/// +public sealed class SyncPlan { + /// + /// Gets all planned actions, sorted by priority (highest first) + /// + public IReadOnlyList Actions { get; init; } = Array.Empty(); + + /// + /// Gets actions that will download files or directories from remote to local + /// + public IReadOnlyList Downloads => Actions.Where(a => a.ActionType == SyncActionType.Download).ToList(); + + /// + /// Gets actions that will upload files or directories from local to remote + /// + public IReadOnlyList Uploads => Actions.Where(a => a.ActionType == SyncActionType.Upload).ToList(); + + /// + /// Gets actions that will delete files or directories from local storage + /// + public IReadOnlyList LocalDeletes => Actions.Where(a => a.ActionType == SyncActionType.DeleteLocal).ToList(); + + /// + /// Gets actions that will delete files or directories from remote storage + /// + public IReadOnlyList RemoteDeletes => Actions.Where(a => a.ActionType == SyncActionType.DeleteRemote).ToList(); + + /// + /// Gets actions representing conflicts that need resolution + /// + public IReadOnlyList Conflicts => Actions.Where(a => a.ActionType == SyncActionType.Conflict).ToList(); + + /// + /// Gets the total number of planned actions + /// + public int TotalActions => Actions.Count; + + /// + /// Gets the total number of files that will be downloaded + /// + public int DownloadCount => Downloads.Count; + + /// + /// Gets the total number of files that will be uploaded + /// + public int UploadCount => Uploads.Count; + + /// + /// Gets the total number of deletions (both local and remote) + /// + public int DeleteCount => LocalDeletes.Count + RemoteDeletes.Count; + + /// + /// Gets the total number of conflicts + /// + public int ConflictCount => Conflicts.Count; + + /// + /// Gets the total size of data that will be downloaded (in bytes) + /// + public long TotalDownloadSize => Downloads.Where(a => !a.IsDirectory).Sum(a => a.Size); + + /// + /// Gets the total size of data that will be uploaded (in bytes) + /// + public long TotalUploadSize => Uploads.Where(a => !a.IsDirectory).Sum(a => a.Size); + + /// + /// Gets whether this plan has any actions to perform + /// + public bool HasChanges => TotalActions > 0; + + /// + /// Gets whether this plan contains any conflicts + /// + public bool HasConflicts => ConflictCount > 0; + + /// + /// Gets a human-readable summary of this plan + /// + /// + /// Example: "3 downloads (2.5 MB), 2 uploads (1.2 MB), 1 delete" + /// + public string Summary { + get { + if (!HasChanges) { + return "No changes to synchronize"; + } + + var parts = new List(); + + if (DownloadCount > 0) { + parts.Add($"{DownloadCount} download{(DownloadCount == 1 ? "" : "s")}" + + (TotalDownloadSize > 0 ? $" ({FormatSize(TotalDownloadSize)})" : "")); + } + + if (UploadCount > 0) { + parts.Add($"{UploadCount} upload{(UploadCount == 1 ? "" : "s")}" + + (TotalUploadSize > 0 ? $" ({FormatSize(TotalUploadSize)})" : "")); + } + + if (DeleteCount > 0) { + parts.Add($"{DeleteCount} delete{(DeleteCount == 1 ? "" : "s")}"); + } + + if (ConflictCount > 0) { + parts.Add($"{ConflictCount} conflict{(ConflictCount == 1 ? "" : "s")}"); + } + + return string.Join(", ", parts); + } + } + + private static string FormatSize(long bytes) { + if (bytes < 1024) { + return $"{bytes} B"; + } + + if (bytes < 1024 * 1024) { + return $"{bytes / 1024.0:F1} KB"; + } + + if (bytes < 1024 * 1024 * 1024) { + return $"{bytes / (1024.0 * 1024.0):F1} MB"; + } + + return $"{bytes / (1024.0 * 1024.0 * 1024.0):F1} GB"; + } +} diff --git a/src/SharpSync/Core/SyncPlanAction.cs b/src/SharpSync/Core/SyncPlanAction.cs new file mode 100644 index 0000000..9a8743e --- /dev/null +++ b/src/SharpSync/Core/SyncPlanAction.cs @@ -0,0 +1,87 @@ +namespace Oire.SharpSync.Core; + +/// +/// Represents a planned synchronization action that will be performed on a file or directory. +/// +/// +/// This class is designed for desktop clients to display detailed sync previews to users before +/// synchronization begins. It provides all the information needed to show what will happen to each file. +/// +public sealed class SyncPlanAction { + /// + /// Gets the type of synchronization action (download, upload, delete, etc.) + /// + public SyncActionType ActionType { get; init; } + + /// + /// Gets the relative path of the file or directory affected by this action + /// + public string Path { get; init; } = string.Empty; + + /// + /// Gets whether this action affects a directory (true) or a file (false) + /// + public bool IsDirectory { get; init; } + + /// + /// Gets the size of the file in bytes (0 for directories or if unknown) + /// + public long Size { get; init; } + + /// + /// Gets the last modification time of the file or directory (if available) + /// + public DateTime? LastModified { get; init; } + + /// + /// Gets the type of conflict if this action represents a conflict, otherwise null + /// + public ConflictType? ConflictType { get; init; } + + /// + /// Gets a human-readable description of this action + /// + /// + /// Examples: "Download document.pdf (1.2 MB)", "Upload Photos/ folder", "Delete old-file.txt from remote" + /// + public string Description { + get { + var sizeStr = IsDirectory ? "folder" : FormatSize(Size); + var pathDisplay = IsDirectory ? $"{Path}/" : Path; + + return ActionType switch { + SyncActionType.Download => $"Download {pathDisplay}" + (IsDirectory ? "" : $" ({sizeStr})"), + SyncActionType.Upload => $"Upload {pathDisplay}" + (IsDirectory ? "" : $" ({sizeStr})"), + SyncActionType.DeleteLocal => $"Delete {pathDisplay} from local storage", + SyncActionType.DeleteRemote => $"Delete {pathDisplay} from remote storage", + SyncActionType.Conflict => $"Resolve conflict for {pathDisplay}" + (ConflictType.HasValue ? $" ({ConflictType.Value})" : ""), + _ => $"Process {pathDisplay}" + }; + } + } + + /// + /// Gets the priority of this action (higher number = higher priority) + /// + /// + /// Used internally for optimization. Desktop clients can use this to sort actions + /// in a way that matches the actual synchronization order. + /// + public int Priority { get; init; } + + private static string FormatSize(long bytes) { + if (bytes < 1024) { + return $"{bytes} B"; + } + + if (bytes < 1024 * 1024) { + return $"{bytes / 1024.0:F1} KB"; + } + + if (bytes < 1024 * 1024 * 1024) { + return $"{bytes / (1024.0 * 1024.0):F1} MB"; + } + + return $"{bytes / (1024.0 * 1024.0 * 1024.0):F1} GB"; + } +} diff --git a/src/SharpSync/Sync/ActionGroups.cs b/src/SharpSync/Sync/ActionGroups.cs new file mode 100644 index 0000000..449a7ee --- /dev/null +++ b/src/SharpSync/Sync/ActionGroups.cs @@ -0,0 +1,22 @@ +namespace Oire.SharpSync.Sync; + +/// +/// Organizes sync actions into optimized processing groups +/// +internal sealed class ActionGroups { + public List Directories { get; } = []; + public List SmallFiles { get; } = []; + public List LargeFiles { get; } = []; + public List Deletes { get; } = []; + public List Conflicts { get; } = []; + + public void SortByPriority() { + Directories.Sort((a, b) => b.Priority.CompareTo(a.Priority)); + SmallFiles.Sort((a, b) => b.Priority.CompareTo(a.Priority)); + LargeFiles.Sort((a, b) => b.Priority.CompareTo(a.Priority)); + Deletes.Sort((a, b) => b.Priority.CompareTo(a.Priority)); + Conflicts.Sort((a, b) => b.Priority.CompareTo(a.Priority)); + } + + public int TotalActions => Directories.Count + SmallFiles.Count + LargeFiles.Count + Deletes.Count + Conflicts.Count; +} diff --git a/src/SharpSync/Sync/AdditionChange.cs b/src/SharpSync/Sync/AdditionChange.cs new file mode 100644 index 0000000..23d38be --- /dev/null +++ b/src/SharpSync/Sync/AdditionChange.cs @@ -0,0 +1,12 @@ +using Oire.SharpSync.Core; + +namespace Oire.SharpSync.Sync; + +/// +/// Represents a new file or directory addition +/// +internal sealed class AdditionChange: IChange { + public string Path { get; set; } = string.Empty; + public SyncItem Item { get; set; } = new(); + public bool IsLocal { get; set; } +} diff --git a/src/SharpSync/Sync/ChangeSet.cs b/src/SharpSync/Sync/ChangeSet.cs new file mode 100644 index 0000000..4c7a537 --- /dev/null +++ b/src/SharpSync/Sync/ChangeSet.cs @@ -0,0 +1,17 @@ +using Oire.SharpSync.Core; + +namespace Oire.SharpSync.Sync; + +/// +/// Represents a set of changes detected during synchronization +/// +internal sealed class ChangeSet { + public List Additions { get; } = []; + public List Modifications { get; } = []; + public List Deletions { get; } = []; + public HashSet ProcessedPaths { get; } = new(StringComparer.OrdinalIgnoreCase); + public HashSet LocalPaths { get; } = new(StringComparer.OrdinalIgnoreCase); + public HashSet RemotePaths { get; } = new(StringComparer.OrdinalIgnoreCase); + + public int TotalChanges => Additions.Count + Modifications.Count + Deletions.Count; +} diff --git a/src/SharpSync/Sync/DeletionChange.cs b/src/SharpSync/Sync/DeletionChange.cs new file mode 100644 index 0000000..440895f --- /dev/null +++ b/src/SharpSync/Sync/DeletionChange.cs @@ -0,0 +1,13 @@ +using Oire.SharpSync.Core; + +namespace Oire.SharpSync.Sync; + +/// +/// Represents a deletion of a file or directory +/// +internal sealed class DeletionChange: IChange { + public string Path { get; set; } = string.Empty; + public bool DeletedLocally { get; set; } + public bool DeletedRemotely { get; set; } + public SyncState TrackedState { get; set; } = new(); +} diff --git a/src/SharpSync/Sync/IChange.cs b/src/SharpSync/Sync/IChange.cs new file mode 100644 index 0000000..3dfe594 --- /dev/null +++ b/src/SharpSync/Sync/IChange.cs @@ -0,0 +1,8 @@ +namespace Oire.SharpSync.Sync; + +/// +/// Represents a change detected during synchronization +/// +internal interface IChange { + string Path { get; } +} diff --git a/src/SharpSync/Sync/ModificationChange.cs b/src/SharpSync/Sync/ModificationChange.cs new file mode 100644 index 0000000..a10c7d5 --- /dev/null +++ b/src/SharpSync/Sync/ModificationChange.cs @@ -0,0 +1,13 @@ +using Oire.SharpSync.Core; + +namespace Oire.SharpSync.Sync; + +/// +/// Represents a modification to an existing file or directory +/// +internal sealed class ModificationChange: IChange { + public string Path { get; set; } = string.Empty; + public SyncItem Item { get; set; } = new(); + public bool IsLocal { get; set; } + public SyncState TrackedState { get; set; } = new(); +} diff --git a/src/SharpSync/Sync/SyncAction.cs b/src/SharpSync/Sync/SyncAction.cs new file mode 100644 index 0000000..5486c84 --- /dev/null +++ b/src/SharpSync/Sync/SyncAction.cs @@ -0,0 +1,15 @@ +using Oire.SharpSync.Core; + +namespace Oire.SharpSync.Sync; + +/// +/// Represents a synchronization action to be performed +/// +internal sealed class SyncAction { + public SyncActionType Type { get; set; } + public string Path { get; set; } = string.Empty; + public SyncItem? LocalItem { get; set; } + public SyncItem? RemoteItem { get; set; } + public ConflictType ConflictType { get; set; } + public int Priority { get; set; } +} diff --git a/src/SharpSync/Sync/SyncEngine.cs b/src/SharpSync/Sync/SyncEngine.cs index 3133841..fffeb16 100644 --- a/src/SharpSync/Sync/SyncEngine.cs +++ b/src/SharpSync/Sync/SyncEngine.cs @@ -177,6 +177,80 @@ public async Task PreviewSyncAsync(SyncOptions? options = null, Canc return await SynchronizeAsync(previewOptions, cancellationToken); } + /// + /// Gets a detailed plan of synchronization actions that will be performed. + /// + /// Optional synchronization options including dry run mode and conflict resolution settings. + /// Cancellation token to cancel the operation. + /// A detailed containing all planned actions with file-level details. + /// Thrown when the sync engine has been disposed. + /// Thrown when the operation is cancelled. + /// + /// This method performs change detection and analyzes what actions would be taken during synchronization, + /// without actually modifying any files. It returns a rich plan object that desktop clients can use to show + /// users a detailed preview including file names, sizes, action types, and conflicts before synchronization begins. + /// + public async Task GetSyncPlanAsync(SyncOptions? options = null, CancellationToken cancellationToken = default) { + if (_disposed) { + throw new ObjectDisposedException(nameof(SyncEngine)); + } + + // Immediately honor a pre-cancelled token + cancellationToken.ThrowIfCancellationRequested(); + + try { + // Detect changes + RaiseProgress(new SyncProgress { CurrentItem = "Analyzing changes..." }, SyncOperation.Scanning); + var changes = await DetectChangesAsync(options, cancellationToken); + + // Check cancellation after detection + cancellationToken.ThrowIfCancellationRequested(); + + if (changes.TotalChanges == 0) { + return new SyncPlan { Actions = Array.Empty() }; + } + + // Analyze and prioritize changes + var actionGroups = AnalyzeAndPrioritizeChanges(changes); + + // Convert internal actions to public plan actions + var planActions = new List(); + + // Combine all action groups + var allActions = actionGroups.Directories + .Concat(actionGroups.SmallFiles) + .Concat(actionGroups.LargeFiles) + .Concat(actionGroups.Deletes) + .Concat(actionGroups.Conflicts) + .OrderByDescending(a => a.Priority); + + foreach (var action in allActions) { + cancellationToken.ThrowIfCancellationRequested(); + + var item = action.LocalItem ?? action.RemoteItem; + + planActions.Add(new SyncPlanAction { + ActionType = action.Type, + Path = action.Path, + IsDirectory = item?.IsDirectory ?? false, + Size = item?.Size ?? 0, + LastModified = item?.LastModified, + ConflictType = action.Type == SyncActionType.Conflict ? action.ConflictType : null, + Priority = action.Priority + }); + } + + return new SyncPlan { + Actions = planActions + }; + } catch (OperationCanceledException) { + throw; + } catch (Exception) { + // Return empty plan on error + return new SyncPlan { Actions = Array.Empty() }; + } + } + /// /// Efficient change detection using database state /// @@ -193,15 +267,21 @@ private async Task DetectChangesAsync(SyncOptions? options, Cancellat await Task.WhenAll(localScanTask, remoteScanTask); - // Detect deletions (items in DB but not found in scans) - foreach (var tracked in trackedItems.Values.Where(t => !changeSet.ProcessedPaths.Contains(t.Path))) { + // Detect deletions by comparing DB state with current storage state + foreach (var tracked in trackedItems.Values) { if (tracked.Status == SyncStatus.Synced || tracked.Status == SyncStatus.LocalModified || tracked.Status == SyncStatus.RemoteModified) { - changeSet.Deletions.Add(new DeletionChange { - Path = tracked.Path, - DeletedLocally = !await _localStorage.ExistsAsync(tracked.Path, cancellationToken), - DeletedRemotely = !await _remoteStorage.ExistsAsync(tracked.Path, cancellationToken), - TrackedState = tracked - }); + var existsLocally = changeSet.LocalPaths.Contains(tracked.Path); + var existsRemotely = changeSet.RemotePaths.Contains(tracked.Path); + + // If item was in DB but is now missing from one or both sides, it's a deletion + if (!existsLocally || !existsRemotely) { + changeSet.Deletions.Add(new DeletionChange { + Path = tracked.Path, + DeletedLocally = !existsLocally, + DeletedRemotely = !existsRemotely, + TrackedState = tracked + }); + } } } @@ -287,6 +367,13 @@ CancellationToken cancellationToken changeSet.ProcessedPaths.Add(item.Path); + // Track which side the item exists on + if (isLocal) { + changeSet.LocalPaths.Add(item.Path); + } else { + changeSet.RemotePaths.Add(item.Path); + } + // Check if item is tracked if (trackedItems.TryGetValue(item.Path, out var tracked)) { // Check for modifications @@ -514,8 +601,12 @@ private static int CalculatePriority(SyncItem item) { private static SyncAction? CreateDeletionAction(DeletionChange deletion) { if (deletion.DeletedLocally) { - // Check if remote was modified - potential conflict - if (deletion.TrackedState.RemoteModified > deletion.TrackedState.LocalModified) { + // Deleted locally, still exists remotely -> delete remote + // Check if remote was modified after last sync - potential conflict + var remoteModified = deletion.TrackedState.RemoteModified; + var localModified = deletion.TrackedState.LocalModified; + + if (remoteModified.HasValue && localModified.HasValue && remoteModified > localModified) { return new SyncAction { Type = SyncActionType.Conflict, Path = deletion.Path, @@ -529,9 +620,13 @@ private static int CalculatePriority(SyncItem item) { Priority = 500 // Medium priority for deletes }; } - } else { - // Check if local was modified - potential conflict - if (deletion.TrackedState.LocalModified > deletion.TrackedState.RemoteModified) { + } else if (deletion.DeletedRemotely) { + // Deleted remotely, still exists locally -> delete local + // Check if local was modified after last sync - potential conflict + var localModified = deletion.TrackedState.LocalModified; + var remoteModified = deletion.TrackedState.RemoteModified; + + if (localModified.HasValue && remoteModified.HasValue && localModified > remoteModified) { return new SyncAction { Type = SyncActionType.Conflict, Path = deletion.Path, @@ -546,6 +641,9 @@ private static int CalculatePriority(SyncItem item) { }; } } + + // Both deleted or invalid state - no action needed + return null; } /// @@ -1033,78 +1131,3 @@ public void Dispose() { } } } - -#region Change Tracking Types - -internal sealed class ChangeSet { - public List Additions { get; } = []; - public List Modifications { get; } = []; - public List Deletions { get; } = []; - public HashSet ProcessedPaths { get; } = new(StringComparer.OrdinalIgnoreCase); - - public int TotalChanges => Additions.Count + Modifications.Count + Deletions.Count; -} - -internal interface IChange { - string Path { get; } -} - -internal sealed class AdditionChange: IChange { - public string Path { get; set; } = string.Empty; - public SyncItem Item { get; set; } = new(); - public bool IsLocal { get; set; } -} - -internal sealed class ModificationChange: IChange { - public string Path { get; set; } = string.Empty; - public SyncItem Item { get; set; } = new(); - public bool IsLocal { get; set; } - public SyncState TrackedState { get; set; } = new(); -} - -internal sealed class DeletionChange: IChange { - public string Path { get; set; } = string.Empty; - public bool DeletedLocally { get; set; } - public bool DeletedRemotely { get; set; } - public SyncState TrackedState { get; set; } = new(); -} - -internal enum SyncActionType { - Download, - Upload, - DeleteLocal, - DeleteRemote, - Conflict -} - -internal sealed class SyncAction { - public SyncActionType Type { get; set; } - public string Path { get; set; } = string.Empty; - public SyncItem? LocalItem { get; set; } - public SyncItem? RemoteItem { get; set; } - public ConflictType ConflictType { get; set; } - public int Priority { get; set; } -} - -/// -/// Organizes sync actions into optimized processing groups -/// -internal sealed class ActionGroups { - public List Directories { get; } = []; - public List SmallFiles { get; } = []; - public List LargeFiles { get; } = []; - public List Deletes { get; } = []; - public List Conflicts { get; } = []; - - public void SortByPriority() { - Directories.Sort((a, b) => b.Priority.CompareTo(a.Priority)); - SmallFiles.Sort((a, b) => b.Priority.CompareTo(a.Priority)); - LargeFiles.Sort((a, b) => b.Priority.CompareTo(a.Priority)); - Deletes.Sort((a, b) => b.Priority.CompareTo(a.Priority)); - Conflicts.Sort((a, b) => b.Priority.CompareTo(a.Priority)); - } - - public int TotalActions => Directories.Count + SmallFiles.Count + LargeFiles.Count + Deletes.Count + Conflicts.Count; -} - -#endregion diff --git a/tests/SharpSync.Tests/Core/SyncPlanActionTests.cs b/tests/SharpSync.Tests/Core/SyncPlanActionTests.cs new file mode 100644 index 0000000..4e918d1 --- /dev/null +++ b/tests/SharpSync.Tests/Core/SyncPlanActionTests.cs @@ -0,0 +1,225 @@ +namespace Oire.SharpSync.Tests.Core; + +public class SyncPlanActionTests { + [Fact] + public void SyncPlanAction_DefaultInitialization_HasEmptyPath() { + // Arrange & Act + var action = new SyncPlanAction(); + + // Assert + Assert.Equal(string.Empty, action.Path); + Assert.Equal(SyncActionType.Download, action.ActionType); + Assert.False(action.IsDirectory); + Assert.Equal(0, action.Size); + Assert.Null(action.LastModified); + Assert.Null(action.ConflictType); + Assert.Equal(0, action.Priority); + } + + [Fact] + public void SyncPlanAction_Download_GeneratesCorrectDescription() { + // Arrange + var action = new SyncPlanAction { + ActionType = SyncActionType.Download, + Path = "documents/report.pdf", + Size = 1024 * 1024 * 2, // 2 MB + IsDirectory = false + }; + + // Act + var description = action.Description; + + // Assert + Assert.Contains("Download", description); + Assert.Contains("documents/report.pdf", description); + Assert.Contains("2.0 MB", description); + } + + [Fact] + public void SyncPlanAction_Upload_GeneratesCorrectDescription() { + // Arrange + var action = new SyncPlanAction { + ActionType = SyncActionType.Upload, + Path = "photos/vacation.jpg", + Size = 1024 * 512, // 512 KB + IsDirectory = false + }; + + // Act + var description = action.Description; + + // Assert + Assert.Contains("Upload", description); + Assert.Contains("photos/vacation.jpg", description); + Assert.Contains("512.0 KB", description); + } + + [Fact] + public void SyncPlanAction_DownloadDirectory_GeneratesCorrectDescription() { + // Arrange + var action = new SyncPlanAction { + ActionType = SyncActionType.Download, + Path = "MyFolder", + Size = 0, + IsDirectory = true + }; + + // Act + var description = action.Description; + + // Assert + Assert.Contains("Download", description); + Assert.Contains("MyFolder/", description); + Assert.DoesNotContain("KB", description); + Assert.DoesNotContain("MB", description); + } + + [Fact] + public void SyncPlanAction_DeleteLocal_GeneratesCorrectDescription() { + // Arrange + var action = new SyncPlanAction { + ActionType = SyncActionType.DeleteLocal, + Path = "old-file.txt", + IsDirectory = false + }; + + // Act + var description = action.Description; + + // Assert + Assert.Contains("Delete", description); + Assert.Contains("old-file.txt", description); + Assert.Contains("local storage", description); + } + + [Fact] + public void SyncPlanAction_DeleteRemote_GeneratesCorrectDescription() { + // Arrange + var action = new SyncPlanAction { + ActionType = SyncActionType.DeleteRemote, + Path = "archive/old-data.bin", + IsDirectory = false + }; + + // Act + var description = action.Description; + + // Assert + Assert.Contains("Delete", description); + Assert.Contains("archive/old-data.bin", description); + Assert.Contains("remote storage", description); + } + + [Fact] + public void SyncPlanAction_Conflict_GeneratesCorrectDescription() { + // Arrange + var action = new SyncPlanAction { + ActionType = SyncActionType.Conflict, + Path = "document.docx", + ConflictType = ConflictType.BothModified, + IsDirectory = false + }; + + // Act + var description = action.Description; + + // Assert + Assert.Contains("Resolve conflict", description); + Assert.Contains("document.docx", description); + Assert.Contains("BothModified", description); + } + + [Fact] + public void SyncPlanAction_ConflictWithoutType_GeneratesCorrectDescription() { + // Arrange + var action = new SyncPlanAction { + ActionType = SyncActionType.Conflict, + Path = "file.txt", + IsDirectory = false + }; + + // Act + var description = action.Description; + + // Assert + Assert.Contains("Resolve conflict", description); + Assert.Contains("file.txt", description); + Assert.DoesNotContain("BothModified", description); + } + + [Theory] + [InlineData(100, "100 B")] + [InlineData(1024, "1.0 KB")] + [InlineData(1536, "1.5 KB")] + [InlineData(1024 * 1024, "1.0 MB")] + [InlineData(1024 * 1024 * 1024, "1.0 GB")] + [InlineData(1024L * 1024L * 1024L * 2, "2.0 GB")] + public void SyncPlanAction_FormatSize_ReturnsCorrectFormat(long bytes, string expected) { + // Arrange + var action = new SyncPlanAction { + ActionType = SyncActionType.Download, + Path = "test.file", + Size = bytes, + IsDirectory = false + }; + + // Act + var description = action.Description; + + // Assert + Assert.Contains(expected, description); + } + + [Fact] + public void SyncPlanAction_WithLastModified_StoresCorrectly() { + // Arrange + var lastModified = new DateTime(2024, 1, 15, 10, 30, 0, DateTimeKind.Utc); + var action = new SyncPlanAction { + ActionType = SyncActionType.Upload, + Path = "data.json", + LastModified = lastModified, + IsDirectory = false + }; + + // Assert + Assert.Equal(lastModified, action.LastModified); + } + + [Fact] + public void SyncPlanAction_WithPriority_StoresCorrectly() { + // Arrange + var action = new SyncPlanAction { + ActionType = SyncActionType.Download, + Path = "important.doc", + Priority = 1000, + IsDirectory = false + }; + + // Assert + Assert.Equal(1000, action.Priority); + } + + [Fact] + public void SyncPlanAction_AllPropertiesSet_PreservesValues() { + // Arrange + var lastModified = DateTime.UtcNow; + var action = new SyncPlanAction { + ActionType = SyncActionType.Upload, + Path = "test/file.txt", + IsDirectory = false, + Size = 2048, + LastModified = lastModified, + ConflictType = ConflictType.ModifiedLocallyDeletedRemotely, + Priority = 500 + }; + + // Assert + Assert.Equal(SyncActionType.Upload, action.ActionType); + Assert.Equal("test/file.txt", action.Path); + Assert.False(action.IsDirectory); + Assert.Equal(2048, action.Size); + Assert.Equal(lastModified, action.LastModified); + Assert.Equal(ConflictType.ModifiedLocallyDeletedRemotely, action.ConflictType); + Assert.Equal(500, action.Priority); + } +} diff --git a/tests/SharpSync.Tests/Core/SyncPlanTests.cs b/tests/SharpSync.Tests/Core/SyncPlanTests.cs new file mode 100644 index 0000000..c6a0bf4 --- /dev/null +++ b/tests/SharpSync.Tests/Core/SyncPlanTests.cs @@ -0,0 +1,330 @@ +namespace Oire.SharpSync.Tests.Core; + +public class SyncPlanTests { + [Fact] + public void SyncPlan_DefaultInitialization_HasEmptyActions() { + // Arrange & Act + var plan = new SyncPlan(); + + // Assert + Assert.Empty(plan.Actions); + Assert.Equal(0, plan.TotalActions); + Assert.False(plan.HasChanges); + Assert.False(plan.HasConflicts); + } + + [Fact] + public void SyncPlan_WithNoActions_ReturnsNoChangesMessage() { + // Arrange + var plan = new SyncPlan { Actions = Array.Empty() }; + + // Act + var summary = plan.Summary; + + // Assert + Assert.Equal("No changes to synchronize", summary); + Assert.False(plan.HasChanges); + } + + [Fact] + public void SyncPlan_WithDownloads_GroupsCorrectly() { + // Arrange + var actions = new List + { + new() { ActionType = SyncActionType.Download, Path = "file1.txt", Size = 1024, IsDirectory = false }, + new() { ActionType = SyncActionType.Download, Path = "file2.txt", Size = 2048, IsDirectory = false }, + new() { ActionType = SyncActionType.Upload, Path = "file3.txt", Size = 512, IsDirectory = false } + }; + var plan = new SyncPlan { Actions = actions }; + + // Act + var downloads = plan.Downloads; + var uploads = plan.Uploads; + + // Assert + Assert.Equal(2, downloads.Count); + Assert.Single(uploads); + Assert.Equal(2, plan.DownloadCount); + Assert.Equal(1, plan.UploadCount); + } + + [Fact] + public void SyncPlan_WithUploads_GroupsCorrectly() { + // Arrange + var actions = new List + { + new() { ActionType = SyncActionType.Upload, Path = "upload1.txt", Size = 1024, IsDirectory = false }, + new() { ActionType = SyncActionType.Upload, Path = "upload2.txt", Size = 2048, IsDirectory = false } + }; + var plan = new SyncPlan { Actions = actions }; + + // Act + var uploads = plan.Uploads; + + // Assert + Assert.Equal(2, uploads.Count); + Assert.Equal(2, plan.UploadCount); + Assert.Equal(0, plan.DownloadCount); + } + + [Fact] + public void SyncPlan_WithDeletes_GroupsCorrectly() { + // Arrange + var actions = new List + { + new() { ActionType = SyncActionType.DeleteLocal, Path = "local1.txt", IsDirectory = false }, + new() { ActionType = SyncActionType.DeleteLocal, Path = "local2.txt", IsDirectory = false }, + new() { ActionType = SyncActionType.DeleteRemote, Path = "remote1.txt", IsDirectory = false } + }; + var plan = new SyncPlan { Actions = actions }; + + // Act + var localDeletes = plan.LocalDeletes; + var remoteDeletes = plan.RemoteDeletes; + + // Assert + Assert.Equal(2, localDeletes.Count); + Assert.Single(remoteDeletes); + Assert.Equal(3, plan.DeleteCount); + } + + [Fact] + public void SyncPlan_WithConflicts_GroupsCorrectly() { + // Arrange + var actions = new List + { + new() { ActionType = SyncActionType.Conflict, Path = "conflict1.txt", ConflictType = ConflictType.BothModified, IsDirectory = false }, + new() { ActionType = SyncActionType.Conflict, Path = "conflict2.txt", ConflictType = ConflictType.DeletedLocallyModifiedRemotely, IsDirectory = false }, + new() { ActionType = SyncActionType.Download, Path = "normal.txt", Size = 1024, IsDirectory = false } + }; + var plan = new SyncPlan { Actions = actions }; + + // Act + var conflicts = plan.Conflicts; + + // Assert + Assert.Equal(2, conflicts.Count); + Assert.Equal(2, plan.ConflictCount); + Assert.True(plan.HasConflicts); + } + + [Fact] + public void SyncPlan_TotalActions_IncludesAllTypes() { + // Arrange + var actions = new List + { + new() { ActionType = SyncActionType.Download, Path = "d1.txt", Size = 100, IsDirectory = false }, + new() { ActionType = SyncActionType.Upload, Path = "u1.txt", Size = 200, IsDirectory = false }, + new() { ActionType = SyncActionType.DeleteLocal, Path = "dl1.txt", IsDirectory = false }, + new() { ActionType = SyncActionType.DeleteRemote, Path = "dr1.txt", IsDirectory = false }, + new() { ActionType = SyncActionType.Conflict, Path = "c1.txt", IsDirectory = false } + }; + var plan = new SyncPlan { Actions = actions }; + + // Assert + Assert.Equal(5, plan.TotalActions); + Assert.True(plan.HasChanges); + } + + [Fact] + public void SyncPlan_TotalDownloadSize_CalculatesCorrectly() { + // Arrange + var actions = new List + { + new() { ActionType = SyncActionType.Download, Path = "file1.txt", Size = 1024, IsDirectory = false }, + new() { ActionType = SyncActionType.Download, Path = "file2.txt", Size = 2048, IsDirectory = false }, + new() { ActionType = SyncActionType.Download, Path = "folder/", Size = 0, IsDirectory = true }, // Should not count + new() { ActionType = SyncActionType.Upload, Path = "file3.txt", Size = 512, IsDirectory = false } // Should not count + }; + var plan = new SyncPlan { Actions = actions }; + + // Assert + Assert.Equal(3072, plan.TotalDownloadSize); // 1024 + 2048 + } + + [Fact] + public void SyncPlan_TotalUploadSize_CalculatesCorrectly() { + // Arrange + var actions = new List + { + new() { ActionType = SyncActionType.Upload, Path = "file1.txt", Size = 500, IsDirectory = false }, + new() { ActionType = SyncActionType.Upload, Path = "file2.txt", Size = 1500, IsDirectory = false }, + new() { ActionType = SyncActionType.Upload, Path = "folder/", Size = 0, IsDirectory = true }, // Should not count + new() { ActionType = SyncActionType.Download, Path = "file3.txt", Size = 1024, IsDirectory = false } // Should not count + }; + var plan = new SyncPlan { Actions = actions }; + + // Assert + Assert.Equal(2000, plan.TotalUploadSize); // 500 + 1500 + } + + [Fact] + public void SyncPlan_Summary_OnlyDownloads_FormatsCorrectly() { + // Arrange + var actions = new List + { + new() { ActionType = SyncActionType.Download, Path = "file1.txt", Size = 1024 * 1024, IsDirectory = false }, + new() { ActionType = SyncActionType.Download, Path = "file2.txt", Size = 512 * 1024, IsDirectory = false } + }; + var plan = new SyncPlan { Actions = actions }; + + // Act + var summary = plan.Summary; + + // Assert + Assert.Contains("2 downloads", summary); + Assert.Contains("1.5 MB", summary); + } + + [Fact] + public void SyncPlan_Summary_OnlyUploads_FormatsCorrectly() { + // Arrange + var actions = new List + { + new() { ActionType = SyncActionType.Upload, Path = "file1.txt", Size = 2 * 1024 * 1024, IsDirectory = false } + }; + var plan = new SyncPlan { Actions = actions }; + + // Act + var summary = plan.Summary; + + // Assert + Assert.Contains("1 upload", summary); // Singular + Assert.Contains("2.0 MB", summary); + } + + [Fact] + public void SyncPlan_Summary_MixedActions_FormatsCorrectly() { + // Arrange + var actions = new List + { + new() { ActionType = SyncActionType.Download, Path = "d1.txt", Size = 1024 * 1024, IsDirectory = false }, + new() { ActionType = SyncActionType.Download, Path = "d2.txt", Size = 1024 * 1024, IsDirectory = false }, + new() { ActionType = SyncActionType.Upload, Path = "u1.txt", Size = 512 * 1024, IsDirectory = false }, + new() { ActionType = SyncActionType.DeleteLocal, Path = "del1.txt", IsDirectory = false } + }; + var plan = new SyncPlan { Actions = actions }; + + // Act + var summary = plan.Summary; + + // Assert + Assert.Contains("2 downloads", summary); + Assert.Contains("2.0 MB", summary); + Assert.Contains("1 upload", summary); + Assert.Contains("512.0 KB", summary); + Assert.Contains("1 delete", summary); + } + + [Fact] + public void SyncPlan_Summary_WithConflicts_IncludesConflicts() { + // Arrange + var actions = new List + { + new() { ActionType = SyncActionType.Download, Path = "d1.txt", Size = 1024, IsDirectory = false }, + new() { ActionType = SyncActionType.Conflict, Path = "c1.txt", IsDirectory = false }, + new() { ActionType = SyncActionType.Conflict, Path = "c2.txt", IsDirectory = false } + }; + var plan = new SyncPlan { Actions = actions }; + + // Act + var summary = plan.Summary; + + // Assert + Assert.Contains("1 download", summary); + Assert.Contains("2 conflicts", summary); + } + + [Fact] + public void SyncPlan_Summary_MultipleDeletes_UsesPluralForm() { + // Arrange + var actions = new List + { + new() { ActionType = SyncActionType.DeleteLocal, Path = "del1.txt", IsDirectory = false }, + new() { ActionType = SyncActionType.DeleteRemote, Path = "del2.txt", IsDirectory = false }, + new() { ActionType = SyncActionType.DeleteLocal, Path = "del3.txt", IsDirectory = false } + }; + var plan = new SyncPlan { Actions = actions }; + + // Act + var summary = plan.Summary; + + // Assert + Assert.Contains("3 deletes", summary); // Plural + } + + [Fact] + public void SyncPlan_Summary_OnlyDirectories_ShowsCountWithoutSize() { + // Arrange + var actions = new List + { + new() { ActionType = SyncActionType.Download, Path = "folder1/", Size = 0, IsDirectory = true }, + new() { ActionType = SyncActionType.Download, Path = "folder2/", Size = 0, IsDirectory = true } + }; + var plan = new SyncPlan { Actions = actions }; + + // Act + var summary = plan.Summary; + + // Assert + Assert.Contains("2 downloads", summary); + // Should not show size or show 0 B + } + + [Fact] + public void SyncPlan_HasChanges_WithActions_ReturnsTrue() { + // Arrange + var actions = new List + { + new() { ActionType = SyncActionType.Download, Path = "file.txt", Size = 100, IsDirectory = false } + }; + var plan = new SyncPlan { Actions = actions }; + + // Assert + Assert.True(plan.HasChanges); + } + + [Fact] + public void SyncPlan_HasConflicts_WithConflicts_ReturnsTrue() { + // Arrange + var actions = new List + { + new() { ActionType = SyncActionType.Conflict, Path = "conflict.txt", IsDirectory = false } + }; + var plan = new SyncPlan { Actions = actions }; + + // Assert + Assert.True(plan.HasConflicts); + } + + [Fact] + public void SyncPlan_HasConflicts_WithoutConflicts_ReturnsFalse() { + // Arrange + var actions = new List + { + new() { ActionType = SyncActionType.Download, Path = "file.txt", Size = 100, IsDirectory = false } + }; + var plan = new SyncPlan { Actions = actions }; + + // Assert + Assert.False(plan.HasConflicts); + } + + [Fact] + public void SyncPlan_LargeDataSizes_FormatsCorrectly() { + // Arrange + var actions = new List + { + new() { ActionType = SyncActionType.Download, Path = "big.iso", Size = 2L * 1024 * 1024 * 1024, IsDirectory = false }, // 2 GB + new() { ActionType = SyncActionType.Upload, Path = "video.mp4", Size = 500L * 1024 * 1024, IsDirectory = false } // 500 MB + }; + var plan = new SyncPlan { Actions = actions }; + + // Act + var summary = plan.Summary; + + // Assert + Assert.Contains("2.0 GB", summary); + Assert.Contains("500.0 MB", summary); + } +} diff --git a/tests/SharpSync.Tests/Sync/SyncEngineTests.cs b/tests/SharpSync.Tests/Sync/SyncEngineTests.cs index af4daa2..2128e60 100644 --- a/tests/SharpSync.Tests/Sync/SyncEngineTests.cs +++ b/tests/SharpSync.Tests/Sync/SyncEngineTests.cs @@ -519,4 +519,328 @@ public async Task SynchronizeAsync_ProgressReporting_ReportsCorrectly() { var lastProgress = progressEvents[^1].Progress.Percentage; Assert.True(lastProgress >= firstProgress); } + + #region GetSyncPlanAsync Tests + + [Fact] + public async Task GetSyncPlanAsync_NoChanges_ReturnsEmptyPlan() { + // Arrange + // Sync once to establish baseline + await _syncEngine.SynchronizeAsync(); + + // Act + var plan = await _syncEngine.GetSyncPlanAsync(); + + // Assert + Assert.NotNull(plan); + Assert.Empty(plan.Actions); + Assert.Equal(0, plan.TotalActions); + Assert.False(plan.HasChanges); + Assert.False(plan.HasConflicts); + Assert.Equal("No changes to synchronize", plan.Summary); + } + + [Fact] + public async Task GetSyncPlanAsync_NewLocalFile_ReturnsUploadAction() { + // Arrange + var filePath = Path.Combine(_localRootPath, "newfile.txt"); + var content = "test content"; + await File.WriteAllTextAsync(filePath, content); + + // Act + var plan = await _syncEngine.GetSyncPlanAsync(); + + // Assert + Assert.NotNull(plan); + Assert.Single(plan.Actions); + Assert.Equal(1, plan.UploadCount); + Assert.Equal(0, plan.DownloadCount); + Assert.True(plan.HasChanges); + Assert.False(plan.HasConflicts); + + var action = plan.Actions[0]; + Assert.Equal(SyncActionType.Upload, action.ActionType); + Assert.Contains("newfile.txt", action.Path); + Assert.False(action.IsDirectory); + Assert.True(action.Size > 0); + Assert.Contains("Upload", action.Description); + } + + [Fact] + public async Task GetSyncPlanAsync_NewRemoteFile_ReturnsDownloadAction() { + // Arrange + var filePath = Path.Combine(_remoteRootPath, "remotefile.txt"); + var content = "remote content"; + await File.WriteAllTextAsync(filePath, content); + + // Act + var plan = await _syncEngine.GetSyncPlanAsync(); + + // Assert + Assert.NotNull(plan); + Assert.Single(plan.Actions); + Assert.Equal(1, plan.DownloadCount); + Assert.Equal(0, plan.UploadCount); + Assert.True(plan.HasChanges); + + var action = plan.Actions[0]; + Assert.Equal(SyncActionType.Download, action.ActionType); + Assert.Contains("remotefile.txt", action.Path); + Assert.False(action.IsDirectory); + Assert.Contains("Download", action.Description); + } + + [Fact] + public async Task GetSyncPlanAsync_NewDirectory_ReturnsCorrectAction() { + // Arrange + var dirPath = Path.Combine(_localRootPath, "NewFolder"); + Directory.CreateDirectory(dirPath); + + // Act + var plan = await _syncEngine.GetSyncPlanAsync(); + + // Assert + Assert.NotNull(plan); + Assert.Single(plan.Actions); + + var action = plan.Actions[0]; + Assert.Equal(SyncActionType.Upload, action.ActionType); + Assert.True(action.IsDirectory); + Assert.Contains("NewFolder", action.Path); + Assert.Contains("folder", action.Description.ToLower()); + } + + [Fact] + public async Task GetSyncPlanAsync_MultipleFiles_ReturnsAllActions() { + // Arrange + // Create 3 local files and 2 remote files + for (int i = 0; i < 3; i++) { + var filePath = Path.Combine(_localRootPath, $"local{i}.txt"); + await File.WriteAllTextAsync(filePath, $"local content {i}"); + } + + for (int i = 0; i < 2; i++) { + var filePath = Path.Combine(_remoteRootPath, $"remote{i}.txt"); + await File.WriteAllTextAsync(filePath, $"remote content {i}"); + } + + // Act + var plan = await _syncEngine.GetSyncPlanAsync(); + + // Assert + Assert.Equal(5, plan.TotalActions); + Assert.Equal(3, plan.UploadCount); + Assert.Equal(2, plan.DownloadCount); + Assert.True(plan.HasChanges); + } + + [Fact] + public async Task GetSyncPlanAsync_DeletedLocalFile_ReturnsDeleteRemoteAction() { + // Arrange + var fileName = "tobedeleted.txt"; + var localPath = Path.Combine(_localRootPath, fileName); + var remotePath = Path.Combine(_remoteRootPath, fileName); + + // Create file in both locations and sync + await File.WriteAllTextAsync(localPath, "content"); + await _syncEngine.SynchronizeAsync(); + + // Delete local file + File.Delete(localPath); + + // Act + var plan = await _syncEngine.GetSyncPlanAsync(); + + // Assert + Assert.Single(plan.Actions); + var action = plan.Actions[0]; + Assert.Equal(SyncActionType.DeleteRemote, action.ActionType); + Assert.Contains(fileName, action.Path); + Assert.Contains("Delete", action.Description); + Assert.Contains("remote", action.Description.ToLower()); + } + + [Fact] + public async Task GetSyncPlanAsync_DeletedRemoteFile_ReturnsDeleteLocalAction() { + // Arrange + var fileName = "tobedeleted.txt"; + var localPath = Path.Combine(_localRootPath, fileName); + var remotePath = Path.Combine(_remoteRootPath, fileName); + + // Create file in both locations and sync + await File.WriteAllTextAsync(localPath, "content"); + await _syncEngine.SynchronizeAsync(); + + // Delete remote file + File.Delete(remotePath); + + // Act + var plan = await _syncEngine.GetSyncPlanAsync(); + + // Assert + Assert.Single(plan.Actions); + var action = plan.Actions[0]; + Assert.Equal(SyncActionType.DeleteLocal, action.ActionType); + Assert.Contains(fileName, action.Path); + Assert.Contains("Delete", action.Description); + Assert.Contains("local", action.Description.ToLower()); + } + + [Fact] + public async Task GetSyncPlanAsync_CalculatesTotalSizes() { + // Arrange + var file1 = Path.Combine(_localRootPath, "file1.txt"); + var file2 = Path.Combine(_remoteRootPath, "file2.txt"); + + await File.WriteAllTextAsync(file1, new string('a', 1024)); // 1 KB + await File.WriteAllTextAsync(file2, new string('b', 2048)); // 2 KB + + // Act + var plan = await _syncEngine.GetSyncPlanAsync(); + + // Assert + Assert.True(plan.TotalUploadSize > 0); + Assert.True(plan.TotalDownloadSize > 0); + Assert.Contains("KB", plan.Summary); + } + + [Fact] + public async Task GetSyncPlanAsync_Summary_FormatsCorrectly() { + // Arrange + var localFile = Path.Combine(_localRootPath, "upload.txt"); + var remoteFile = Path.Combine(_remoteRootPath, "download.txt"); + + await File.WriteAllTextAsync(localFile, "content to upload"); + await File.WriteAllTextAsync(remoteFile, "content to download"); + + // Act + var plan = await _syncEngine.GetSyncPlanAsync(); + + // Assert + var summary = plan.Summary; + Assert.NotNull(summary); + Assert.NotEmpty(summary); + Assert.DoesNotContain("No changes", summary); + } + + [Fact] + public async Task GetSyncPlanAsync_GroupsActionsByType() { + // Arrange + // Create files for upload + await File.WriteAllTextAsync(Path.Combine(_localRootPath, "upload1.txt"), "content"); + await File.WriteAllTextAsync(Path.Combine(_localRootPath, "upload2.txt"), "content"); + + // Create files for download + await File.WriteAllTextAsync(Path.Combine(_remoteRootPath, "download1.txt"), "content"); + + // Act + var plan = await _syncEngine.GetSyncPlanAsync(); + + // Assert + Assert.Equal(2, plan.Uploads.Count); + Assert.Single(plan.Downloads); + Assert.Empty(plan.LocalDeletes); + Assert.Empty(plan.RemoteDeletes); + Assert.Empty(plan.Conflicts); + } + + [Fact] + public async Task GetSyncPlanAsync_DoesNotModifyFiles() { + // Arrange + var localFile = Path.Combine(_localRootPath, "test.txt"); + await File.WriteAllTextAsync(localFile, "original content"); + + // Act + var plan = await _syncEngine.GetSyncPlanAsync(); + + // Assert - file should still exist locally and not on remote + Assert.True(File.Exists(localFile)); + Assert.False(File.Exists(Path.Combine(_remoteRootPath, "test.txt"))); + + // Verify plan has the action but it wasn't executed + Assert.Single(plan.Actions); + Assert.Equal(SyncActionType.Upload, plan.Actions[0].ActionType); + } + + [Fact] + public async Task GetSyncPlanAsync_WithCancellation_ThrowsOperationCanceledException() { + // Arrange + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + // Act & Assert + await Assert.ThrowsAsync( + async () => await _syncEngine.GetSyncPlanAsync(cancellationToken: cts.Token) + ); + } + + [Fact] + public async Task GetSyncPlanAsync_AfterDispose_ThrowsObjectDisposedException() { + // Arrange + _syncEngine.Dispose(); + + // Act & Assert + await Assert.ThrowsAsync( + async () => await _syncEngine.GetSyncPlanAsync() + ); + } + + [Fact] + public async Task GetSyncPlanAsync_PriorityOrdering_MaintainsPriority() { + // Arrange + // Create directory and files (directories should have higher priority) + Directory.CreateDirectory(Path.Combine(_localRootPath, "Folder")); + await File.WriteAllTextAsync(Path.Combine(_localRootPath, "file1.txt"), "content"); + await File.WriteAllTextAsync(Path.Combine(_localRootPath, "file2.txt"), "content"); + + // Act + var plan = await _syncEngine.GetSyncPlanAsync(); + + // Assert + Assert.True(plan.Actions.Count >= 3); + + // Actions should maintain priority ordering + for (int i = 0; i < plan.Actions.Count - 1; i++) { + Assert.True(plan.Actions[i].Priority >= plan.Actions[i + 1].Priority); + } + } + + [Fact] + public async Task GetSyncPlanAsync_IncludesLastModifiedTime() { + // Arrange + var filePath = Path.Combine(_localRootPath, "timestamped.txt"); + await File.WriteAllTextAsync(filePath, "content with timestamp"); + + // Act + var plan = await _syncEngine.GetSyncPlanAsync(); + + // Assert + Assert.Single(plan.Actions); + var action = plan.Actions[0]; + Assert.NotNull(action.LastModified); + Assert.True(action.LastModified.Value.Year >= 2024); + } + + [Fact] + public async Task GetSyncPlanAsync_WithOptions_RespectsFilterSettings() { + // Arrange + await File.WriteAllTextAsync(Path.Combine(_localRootPath, "included.txt"), "content"); + await File.WriteAllTextAsync(Path.Combine(_localRootPath, "excluded.tmp"), "content"); + + var filter = new SyncFilter(); + filter.AddExclusionPattern("*.tmp"); + + var conflictResolver = new DefaultConflictResolver(ConflictResolution.UseLocal); + using var filteredEngine = new SyncEngine(_localStorage, _remoteStorage, _database, filter, conflictResolver); + + // Act + var plan = await filteredEngine.GetSyncPlanAsync(); + + // Assert + Assert.Single(plan.Actions); + Assert.Contains("included.txt", plan.Actions[0].Path); + Assert.DoesNotContain(plan.Actions, a => a.Path.Contains("excluded.tmp")); + } + + #endregion }