Files
Explorer-Workbench/tests/Explorer.Application.Tests/FolderSyncPlannerTests.cs

241 lines
8.8 KiB
C#

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('\\');
}