241 lines
8.8 KiB
C#
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('\\');
|
|
}
|