Add Git overlay, operation tools, and virtualized preview so large folders stay responsive.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
240
tests/Explorer.Application.Tests/FolderSyncPlannerTests.cs
Normal file
240
tests/Explorer.Application.Tests/FolderSyncPlannerTests.cs
Normal file
@@ -0,0 +1,240 @@
|
||||
using Explorer.Application;
|
||||
using Explorer.Domain;
|
||||
using Explorer.Domain.Abstractions;
|
||||
|
||||
namespace Explorer.Application.Tests;
|
||||
|
||||
public class FolderSyncPlannerTests
|
||||
{
|
||||
private static readonly DateTimeOffset T0 = DateTimeOffset.Parse("2024-06-01T12:00:00Z");
|
||||
|
||||
[Fact]
|
||||
public void Copy_update_adds_and_overwrites_but_does_not_delete()
|
||||
{
|
||||
var fs = Tree()
|
||||
.Dir(@"C:\src")
|
||||
.File(@"C:\src\new.txt", 10, T0)
|
||||
.File(@"C:\src\same.txt", 20, T0)
|
||||
.File(@"C:\src\changed.txt", 30, T0)
|
||||
.Dir(@"C:\src\empty")
|
||||
.Dir(@"C:\dst")
|
||||
.File(@"C:\dst\same.txt", 20, T0)
|
||||
.File(@"C:\dst\changed.txt", 11, T0.AddSeconds(-10))
|
||||
.File(@"C:\dst\orphan.txt", 4, T0);
|
||||
|
||||
var plan = Build(fs, SyncMode.CopyUpdate);
|
||||
Assert.True(plan.CanEnqueue);
|
||||
Assert.DoesNotContain(plan.Operations, o => o.Op == TransferOp.Delete);
|
||||
Assert.Contains(plan.Operations, o => o.Op == TransferOp.Copy && o.SourcePath == @"C:\src\new.txt");
|
||||
Assert.Contains(plan.Operations, o => o.Op == TransferOp.Copy && o.SourcePath == @"C:\src\changed.txt");
|
||||
Assert.Contains(plan.SyncPreview, r => r.RelativePath == "same.txt" && r.Action == "Skip");
|
||||
Assert.Contains(plan.SyncPreview, r => r.RelativePath == "empty" && r.Action == "Copy");
|
||||
Assert.DoesNotContain(plan.SyncPreview, r => r.RelativePath == "orphan.txt");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dest_newer_is_skipped_with_a_warning()
|
||||
{
|
||||
var fs = Tree()
|
||||
.Dir(@"C:\src")
|
||||
.File(@"C:\src\photo.jpg", 100, T0)
|
||||
.Dir(@"C:\dst")
|
||||
.File(@"C:\dst\photo.jpg", 80, T0.AddSeconds(5));
|
||||
|
||||
var plan = Build(fs, SyncMode.CopyUpdate);
|
||||
Assert.False(plan.CanEnqueue);
|
||||
Assert.Empty(plan.Operations);
|
||||
Assert.Contains(plan.Issues, i => i.Severity == PlanIssueSeverity.Warning && i.Message.Contains("newer"));
|
||||
Assert.Contains(plan.SyncPreview, r => r.Action == "Skip" && r.Detail == "Destination is newer.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mirror_lists_destination_only_deletes()
|
||||
{
|
||||
var fs = Tree()
|
||||
.Dir(@"C:\src")
|
||||
.File(@"C:\src\keep.txt", 8, T0)
|
||||
.Dir(@"C:\dst")
|
||||
.File(@"C:\dst\keep.txt", 8, T0)
|
||||
.File(@"C:\dst\gone.txt", 3, T0)
|
||||
.Dir(@"C:\dst\old");
|
||||
|
||||
var plan = Build(fs, SyncMode.Mirror);
|
||||
Assert.True(plan.CanEnqueue);
|
||||
Assert.Contains(plan.Operations, o => o.Op == TransferOp.Delete && o.SourcePath == @"C:\dst\gone.txt");
|
||||
Assert.Contains(plan.Operations, o => o.Op == TransferOp.Delete && o.SourcePath == @"C:\dst\old");
|
||||
Assert.Contains(plan.SyncPreview, r => r.RelativePath == "gone.txt" && r.Action == "Delete");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Nested_or_same_folders_are_errors()
|
||||
{
|
||||
var fs = Tree().Dir(@"C:\src").Dir(@"C:\src\nested");
|
||||
var planner = new FolderSyncPlanner();
|
||||
var same = planner.Build(@"C:\src", @"C:\src", SyncMode.CopyUpdate, "", fs, _ => true);
|
||||
Assert.False(same.CanEnqueue);
|
||||
Assert.Contains(same.Issues, i => i.Message.Contains("different", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
var nested = planner.Build(@"C:\src", @"C:\src\nested", SyncMode.CopyUpdate, "", fs, _ => true);
|
||||
Assert.False(nested.CanEnqueue);
|
||||
Assert.Contains(nested.Issues, i => i.Message.Contains("contain", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Offline_destination_is_an_error_not_an_empty_mirror()
|
||||
{
|
||||
var fs = Tree()
|
||||
.Dir(@"C:\src")
|
||||
.File(@"C:\src\a.txt", 1, T0)
|
||||
.Dir(@"Z:\backup")
|
||||
.File(@"Z:\backup\a.txt", 1, T0)
|
||||
.File(@"Z:\backup\only-dest.txt", 2, T0);
|
||||
|
||||
var plan = new FolderSyncPlanner().Build(
|
||||
@"C:\src",
|
||||
@"Z:\backup",
|
||||
SyncMode.Mirror,
|
||||
"",
|
||||
fs,
|
||||
path => path.StartsWith(@"C:\", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.False(plan.CanEnqueue);
|
||||
Assert.Empty(plan.Operations);
|
||||
Assert.Contains(plan.Issues, i => i.Message == "Destination is not available.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Copy_update_allows_a_missing_dest_folder_when_the_parent_is_reachable()
|
||||
{
|
||||
var fs = Tree()
|
||||
.Dir(@"C:\src")
|
||||
.File(@"C:\src\a.txt", 1, T0)
|
||||
.Dir(@"C:\dst");
|
||||
|
||||
var plan = new FolderSyncPlanner().Build(
|
||||
@"C:\src",
|
||||
@"C:\dst\new",
|
||||
SyncMode.CopyUpdate,
|
||||
"",
|
||||
fs,
|
||||
path => !path.Equals(@"C:\dst\new", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.True(plan.CanEnqueue);
|
||||
Assert.Contains(plan.Operations, o => o.Op == TransferOp.Copy && o.DestinationPath == @"C:\dst\new\a.txt");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Excludes_and_online_only_files_are_skipped()
|
||||
{
|
||||
var fs = Tree()
|
||||
.Dir(@"C:\src")
|
||||
.File(@"C:\src\keep.txt", 1, T0)
|
||||
.File(@"C:\src\skip.tmp", 1, T0)
|
||||
.File(@"C:\src\cloud.bin", 1, T0)
|
||||
.Dir(@"C:\dst");
|
||||
|
||||
var plan = new FolderSyncPlanner().Build(
|
||||
@"C:\src",
|
||||
@"C:\dst",
|
||||
SyncMode.CopyUpdate,
|
||||
"*.tmp",
|
||||
fs,
|
||||
_ => true,
|
||||
item => item.Name == "cloud.bin");
|
||||
Assert.Contains(plan.Operations, o => o.SourcePath.EndsWith("keep.txt", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.DoesNotContain(plan.Operations, o => o.SourcePath.EndsWith("skip.tmp", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.DoesNotContain(plan.Operations, o => o.SourcePath.EndsWith("cloud.bin", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.Contains(plan.Issues, i => i.Message.Contains("Online-only", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reparse_directories_are_not_walked_or_copied()
|
||||
{
|
||||
var fs = Tree()
|
||||
.Dir(@"C:\src")
|
||||
.Dir(@"C:\src\link", AttributeFlags.Directory | AttributeFlags.ReparsePoint)
|
||||
.File(@"C:\src\link\secret.txt", 9, T0)
|
||||
.Dir(@"C:\dst");
|
||||
|
||||
var plan = Build(fs, SyncMode.CopyUpdate);
|
||||
Assert.DoesNotContain(plan.Operations, o => o.SourcePath.Contains("secret", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.DoesNotContain(plan.Operations, o => o.SourcePath.EndsWith(@"\link", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Remap_replaces_the_volume_root_when_the_guid_is_unique()
|
||||
{
|
||||
var sources = new List<Source>
|
||||
{
|
||||
new()
|
||||
{
|
||||
StableKey = "usb",
|
||||
DisplayName = "Photos",
|
||||
VolumeGuid = @"{abc}",
|
||||
LastRootPath = @"F:\"
|
||||
}
|
||||
};
|
||||
Assert.Equal(@"F:\photos", FolderSyncPlanner.RemapToVolume(@"E:\photos", "{abc}", sources));
|
||||
Assert.Equal(@"E:\photos", FolderSyncPlanner.RemapToVolume(@"E:\photos", "{abc}", []));
|
||||
}
|
||||
|
||||
private static OperationPlan Build(IFileSystemEnumerator fs, SyncMode mode)
|
||||
=> new FolderSyncPlanner().Build(@"C:\src", @"C:\dst", mode, "", fs, _ => true);
|
||||
|
||||
private static TreeEnumerator Tree() => new();
|
||||
}
|
||||
|
||||
sealed class TreeEnumerator : IFileSystemEnumerator
|
||||
{
|
||||
private readonly Dictionary<string, FileSystemItem> _items = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<string, List<FileSystemItem>> _children = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public TreeEnumerator Dir(string path, int attributes = AttributeFlags.Directory)
|
||||
{
|
||||
Add(new FileSystemItem
|
||||
{
|
||||
FullPath = path,
|
||||
Name = PathRules.GetFileName(path),
|
||||
IsDirectory = true,
|
||||
Attributes = attributes
|
||||
});
|
||||
return this;
|
||||
}
|
||||
|
||||
public TreeEnumerator File(string path, long size, DateTimeOffset modified)
|
||||
{
|
||||
Add(new FileSystemItem
|
||||
{
|
||||
FullPath = path,
|
||||
Name = PathRules.GetFileName(path),
|
||||
SizeBytes = size,
|
||||
ModifiedUtc = modified
|
||||
});
|
||||
return this;
|
||||
}
|
||||
|
||||
public IEnumerable<FileSystemItem> EnumerateChildren(string directoryPath)
|
||||
=> EnumerateChildrenSafe(directoryPath, out _);
|
||||
|
||||
public FileSystemItem? GetItem(string path)
|
||||
=> _items.TryGetValue(Key(path), out var item) ? item : null;
|
||||
|
||||
public IReadOnlyList<FileSystemItem> EnumerateChildrenSafe(string directoryPath, out string? error)
|
||||
{
|
||||
error = null;
|
||||
return _children.TryGetValue(Key(directoryPath), out var list) ? list : [];
|
||||
}
|
||||
|
||||
private void Add(FileSystemItem item)
|
||||
{
|
||||
_items[Key(item.FullPath)] = item;
|
||||
var parent = PathRules.Parent(item.FullPath);
|
||||
if (!_children.TryGetValue(Key(parent), out var list))
|
||||
{
|
||||
list = [];
|
||||
_children[Key(parent)] = list;
|
||||
}
|
||||
|
||||
list.Add(item);
|
||||
}
|
||||
|
||||
private static string Key(string path) => PathRules.FromExtended(path).TrimEnd('\\');
|
||||
}
|
||||
Reference in New Issue
Block a user