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:
@@ -136,4 +136,90 @@ public class AnalysisTests
|
||||
var local = await store.Entries.GetByPathAsync(source.Id, "local.bin");
|
||||
Assert.Equal(HashState.Partial, local!.HashState);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Hardlinks_are_grouped_but_excluded_from_default_duplicate_list()
|
||||
{
|
||||
var (store, analysis, source) = await SeedHashedAsync(fileIdA: 42, fileIdB: 42);
|
||||
await using (store)
|
||||
{
|
||||
var raw = await store.Hashes.GetDuplicateGroupsAsync(null, null, 10);
|
||||
var group = Assert.Single(raw);
|
||||
Assert.True(group.SameFileId);
|
||||
|
||||
var classified = await analysis.GetClassifiedDuplicatesAsync();
|
||||
var item = Assert.Single(classified);
|
||||
Assert.Equal(DuplicateClass.Hardlink, item.Classification);
|
||||
Assert.True(DuplicateClassifier.IsHiddenByDefault(item.Classification));
|
||||
Assert.Equal(source.Id, item.Group.Entries[0].SourceId);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Marked_intentional_relation_is_excluded_from_default_list()
|
||||
{
|
||||
var (store, analysis, _) = await SeedHashedAsync(fileIdA: 1, fileIdB: 2);
|
||||
await using (store)
|
||||
{
|
||||
var before = Assert.Single(await analysis.GetClassifiedDuplicatesAsync());
|
||||
Assert.Equal(DuplicateClass.Unknown, before.Classification);
|
||||
Assert.False(DuplicateClassifier.IsHiddenByDefault(before.Classification));
|
||||
|
||||
await analysis.MarkDuplicateGroupAsync(before.Group.Entries, FileRelationKind.IntentionalDuplicate);
|
||||
var after = Assert.Single(await analysis.GetClassifiedDuplicatesAsync());
|
||||
Assert.Equal(DuplicateClass.Intentional, after.Classification);
|
||||
Assert.True(DuplicateClassifier.IsHiddenByDefault(after.Classification));
|
||||
|
||||
await analysis.MarkDuplicateGroupAsync(after.Group.Entries, FileRelationKind.AccidentalDuplicate);
|
||||
var accidental = Assert.Single(await analysis.GetClassifiedDuplicatesAsync());
|
||||
Assert.Equal(DuplicateClass.Accidental, accidental.Classification);
|
||||
Assert.False(DuplicateClassifier.IsHiddenByDefault(accidental.Classification));
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<(SqliteIndexStore Store, AnalysisService Analysis, Source Source)> SeedHashedAsync(
|
||||
long fileIdA, long fileIdB)
|
||||
{
|
||||
var db = Path.Combine(Path.GetTempPath(), "ew-dup", Guid.NewGuid().ToString("N"), "index.db");
|
||||
var store = new SqliteIndexStore(db, NullLogger<SqliteIndexStore>.Instance);
|
||||
await store.OpenAsync();
|
||||
var source = new Source
|
||||
{
|
||||
StableKey = "d",
|
||||
DisplayName = "D",
|
||||
Kind = SourceKind.NtfsLocal,
|
||||
LastRootPath = @"C:\d",
|
||||
Status = SourceStatus.Online
|
||||
};
|
||||
source.Id = await store.Sources.UpsertAsync(source);
|
||||
var hash = Enumerable.Repeat((byte)7, 32).ToArray();
|
||||
var root = new IndexEntry
|
||||
{
|
||||
SourceId = source.Id,
|
||||
Name = "d",
|
||||
NameNorm = "d",
|
||||
IsDirectory = true,
|
||||
PathRel = "",
|
||||
LastSeenUtc = DateTimeOffset.UtcNow
|
||||
};
|
||||
root.Id = await store.Entries.UpsertAsync(root);
|
||||
await store.Entries.UpsertAsync(Hashed("a.bin", fileIdA, source.Id, root.Id, hash));
|
||||
await store.Entries.UpsertAsync(Hashed("b.bin", fileIdB, source.Id, root.Id, hash));
|
||||
return (store, new AnalysisService(store), source);
|
||||
}
|
||||
|
||||
private static IndexEntry Hashed(string name, long fileId, long sourceId, long parentId, byte[] hash)
|
||||
=> new()
|
||||
{
|
||||
SourceId = sourceId,
|
||||
ParentId = parentId,
|
||||
Name = name,
|
||||
NameNorm = name,
|
||||
SizeBytes = 64,
|
||||
PathRel = name,
|
||||
FileId = fileId,
|
||||
ContentHash = hash,
|
||||
HashState = HashState.Full,
|
||||
LastSeenUtc = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
373
tests/Explorer.Application.Tests/BrowseHydrationTests.cs
Normal file
373
tests/Explorer.Application.Tests/BrowseHydrationTests.cs
Normal file
@@ -0,0 +1,373 @@
|
||||
using Explorer.Application;
|
||||
using Explorer.Domain;
|
||||
using Explorer.Domain.Abstractions;
|
||||
using Explorer.Plugin.Abstractions;
|
||||
using Explorer.Storage.Sqlite;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace Explorer.Application.Tests;
|
||||
|
||||
public class BrowseHydrationTests
|
||||
{
|
||||
[Fact]
|
||||
public void Visible_rows_are_hydrated_before_distant_rows()
|
||||
{
|
||||
var items = Enumerable.Range(0, 100)
|
||||
.Select(i => new FileSystemItem { FullPath = $@"C:\f{i}", Name = $"f{i}" })
|
||||
.ToList();
|
||||
var ordered = BrowseHydration.Prioritize(items, [@"C:\f50"]);
|
||||
Assert.Equal(@"C:\f50", ordered[0].FullPath);
|
||||
Assert.Contains(ordered.Take(BrowseHydration.NearbyWindow + 1), i => i.FullPath == @"C:\f49");
|
||||
Assert.Equal(@"C:\f99", ordered[^1].FullPath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Network_and_cloud_use_tighter_batches_than_local()
|
||||
{
|
||||
Assert.Equal(BrowseHydration.LocalProviderBatch, BrowseHydration.ProviderBatchSize(SourceKind.NtfsLocal, false));
|
||||
Assert.Equal(BrowseHydration.ConstrainedProviderBatch, BrowseHydration.ProviderBatchSize(SourceKind.Smb, false));
|
||||
Assert.Equal(BrowseHydration.ConstrainedProviderBatch, BrowseHydration.ProviderBatchSize(SourceKind.NtfsLocal, true));
|
||||
Assert.Equal(BrowseHydration.ConstrainedWorkers, BrowseHydration.WorkerCount(SourceKind.Smb, false));
|
||||
Assert.Equal(BrowseHydration.LocalWorkers, BrowseHydration.WorkerCount(SourceKind.NtfsLocal, false));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Size_sort_is_deferred_until_folder_metadata_is_ready()
|
||||
{
|
||||
Assert.True(FolderListingSort.ShouldDeferAutoSort("Size", enumerationComplete: false, sizeMetadataReady: true));
|
||||
Assert.True(FolderListingSort.ShouldDeferAutoSort("Size", enumerationComplete: true, sizeMetadataReady: false));
|
||||
Assert.False(FolderListingSort.ShouldDeferAutoSort("Size", enumerationComplete: true, sizeMetadataReady: true));
|
||||
Assert.False(FolderListingSort.ShouldDeferAutoSort("Name", enumerationComplete: true, sizeMetadataReady: false));
|
||||
Assert.False(FolderListingSort.DependsOnIncompleteMetadata("Modified"));
|
||||
Assert.True(FolderListingSort.DependsOnIncompleteMetadata("Size"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Name_sort_keeps_folders_first()
|
||||
{
|
||||
FileSystemItem[] items =
|
||||
[
|
||||
new() { FullPath = @"C:\z.txt", Name = "z.txt" },
|
||||
new() { FullPath = @"C:\A", Name = "A", IsDirectory = true },
|
||||
new() { FullPath = @"C:\b.txt", Name = "b.txt" }
|
||||
];
|
||||
var ordered = FolderListingSort.Order(items, "Name", descending: false).Select(i => i.Name).ToList();
|
||||
Assert.Equal(["A", "b.txt", "z.txt"], ordered);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Progressive_listing_publishes_rows_before_provider_enrichment()
|
||||
{
|
||||
var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var (browse, store, _) = await CreateAsync(new GatedCloudProvider(@"C:\", gate));
|
||||
try
|
||||
{
|
||||
await using var it = browse.ListProgressiveAsync(@"C:\").GetAsyncEnumerator();
|
||||
Assert.True(await it.MoveNextAsync());
|
||||
Assert.NotEmpty(it.Current.Added);
|
||||
Assert.Contains(it.Current.Added, i => i.Name == "Movies");
|
||||
Assert.All(it.Current.Added, i => Assert.Null(i.Cloud));
|
||||
Assert.False(it.Current.HydrationComplete);
|
||||
Assert.False(gate.Task.IsCompleted);
|
||||
|
||||
gate.SetResult();
|
||||
var sawCloud = false;
|
||||
var complete = it.Current.HydrationComplete;
|
||||
while (!complete && await it.MoveNextAsync())
|
||||
{
|
||||
sawCloud |= it.Current.Updated.Any(u => u.Cloud is not null) || it.Current.Added.Any(a => a.Cloud is not null);
|
||||
complete = it.Current.HydrationComplete;
|
||||
}
|
||||
|
||||
Assert.True(complete);
|
||||
Assert.True(sawCloud);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await store.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Progressive_listing_cancels_without_applying_stale_provider_results()
|
||||
{
|
||||
var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var provider = new GatedCloudProvider(@"C:\", gate);
|
||||
var (browse, store, _) = await CreateAsync(provider);
|
||||
try
|
||||
{
|
||||
using var cts = new CancellationTokenSource();
|
||||
await using var it = browse.ListProgressiveAsync(@"C:\", viewport: null, cts.Token).GetAsyncEnumerator();
|
||||
Assert.True(await it.MoveNextAsync());
|
||||
cts.Cancel();
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(async () =>
|
||||
{
|
||||
while (await it.MoveNextAsync())
|
||||
{
|
||||
}
|
||||
});
|
||||
Assert.Equal(0, provider.CompletedCalls);
|
||||
gate.SetResult();
|
||||
await Task.Delay(50);
|
||||
Assert.Equal(0, provider.CompletedCalls);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await store.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Provider_enrichment_uses_bounded_batches()
|
||||
{
|
||||
var provider = new RecordingProvider(@"C:\");
|
||||
var items = Enumerable.Range(0, 80)
|
||||
.Select(i => new FileSystemItem { FullPath = $@"C:\f{i}.txt", Name = $"f{i}.txt", SizeBytes = i })
|
||||
.ToList();
|
||||
var (browse, store, _) = await CreateAsync(provider, new FixedEnumerator(items));
|
||||
try
|
||||
{
|
||||
var listing = await browse.ListAsync(@"C:\");
|
||||
Assert.Equal(80, listing.Items.Count);
|
||||
Assert.NotEmpty(provider.BatchSizes);
|
||||
Assert.All(provider.BatchSizes, size => Assert.InRange(size, 1, BrowseHydration.ConstrainedProviderBatch));
|
||||
Assert.Equal(80, provider.BatchSizes.Sum());
|
||||
}
|
||||
finally
|
||||
{
|
||||
await store.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Live_listing_does_not_open_items_with_GetItem()
|
||||
{
|
||||
var enumerator = new GuardedEnumerator();
|
||||
var (browse, store, _) = await CreateAsync(enumerator: enumerator);
|
||||
try
|
||||
{
|
||||
var listing = await browse.ListAsync(@"C:\");
|
||||
Assert.Contains(listing.Items, i => i.Name == "Movies");
|
||||
Assert.Equal(0, enumerator.GetItemCalls);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await store.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListAsync_still_returns_index_folder_sizes()
|
||||
{
|
||||
var (browse, store, _) = await CreateAsync();
|
||||
try
|
||||
{
|
||||
var source = (await store.Sources.GetAllAsync()).Single();
|
||||
source.LastIndexedUtc = DateTimeOffset.UtcNow;
|
||||
await store.Sources.UpsertAsync(source);
|
||||
var root = new IndexEntry
|
||||
{
|
||||
SourceId = source.Id,
|
||||
Name = "C:",
|
||||
NameNorm = "c:",
|
||||
IsDirectory = true,
|
||||
PathRel = "",
|
||||
LastSeenUtc = DateTimeOffset.UtcNow
|
||||
};
|
||||
root.Id = await store.Entries.UpsertAsync(root);
|
||||
await store.Entries.UpsertAsync(new IndexEntry
|
||||
{
|
||||
SourceId = source.Id,
|
||||
ParentId = root.Id,
|
||||
Name = "Movies",
|
||||
NameNorm = "movies",
|
||||
IsDirectory = true,
|
||||
PathRel = "Movies",
|
||||
LastSeenUtc = DateTimeOffset.UtcNow,
|
||||
AggregateSize = 200
|
||||
});
|
||||
|
||||
var listing = await browse.ListAsync(@"C:\");
|
||||
var movies = Assert.Single(listing.Items, i => i.Name == "Movies");
|
||||
Assert.Equal(200, movies.SizeBytes);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await store.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<(BrowseService Browse, SqliteIndexStore Store, IAppEnvironment Env)> CreateAsync(
|
||||
IStorageProvider? provider = null,
|
||||
IFileSystemEnumerator? enumerator = null)
|
||||
{
|
||||
var db = Path.Combine(Path.GetTempPath(), "ew-browse-hyd", Guid.NewGuid().ToString("N"), "index.db");
|
||||
var store = new SqliteIndexStore(db, NullLogger<SqliteIndexStore>.Instance);
|
||||
await store.OpenAsync();
|
||||
var volumes = new HydrationVolumes
|
||||
{
|
||||
Online =
|
||||
[
|
||||
new VolumeFingerprint
|
||||
{
|
||||
Kind = SourceKind.NtfsLocal,
|
||||
RootPath = @"C:\",
|
||||
DisplayName = "C:",
|
||||
VolumeSerial = 1,
|
||||
CapacityBytes = 1_000_000_000,
|
||||
FreeBytes = 250_000_000
|
||||
}
|
||||
]
|
||||
};
|
||||
var env = new HydrationEnv(Path.GetDirectoryName(db)!);
|
||||
var sources = new SourceManager(store, volumes, env, new SystemClock(), NullLogger<SourceManager>.Instance);
|
||||
await sources.InitializeAsync();
|
||||
var providers = provider is null ? Array.Empty<IStorageProvider>() : new[] { provider };
|
||||
var browse = new BrowseService(
|
||||
enumerator ?? new GuardedEnumerator(),
|
||||
volumes,
|
||||
store,
|
||||
sources,
|
||||
new StorageProviderRegistry(providers, NullLogger<StorageProviderRegistry>.Instance),
|
||||
new CloudPlaceStore(env),
|
||||
new UiPreferencesStore(env));
|
||||
return (browse, store, env);
|
||||
}
|
||||
}
|
||||
|
||||
file sealed class GuardedEnumerator : IFileSystemEnumerator
|
||||
{
|
||||
public int GetItemCalls { get; private set; }
|
||||
|
||||
public IEnumerable<FileSystemItem> EnumerateChildren(string directoryPath)
|
||||
=> EnumerateChildrenSafe(directoryPath, out _);
|
||||
|
||||
public FileSystemItem? GetItem(string path)
|
||||
{
|
||||
GetItemCalls++;
|
||||
throw new InvalidOperationException("GetItem must not run during folder listing.");
|
||||
}
|
||||
|
||||
public IReadOnlyList<FileSystemItem> EnumerateChildrenSafe(string directoryPath, out string? error)
|
||||
{
|
||||
error = null;
|
||||
return
|
||||
[
|
||||
new FileSystemItem { FullPath = Path.Combine(directoryPath, "Movies"), Name = "Movies", IsDirectory = true },
|
||||
new FileSystemItem { FullPath = Path.Combine(directoryPath, "loose.txt"), Name = "loose.txt", SizeBytes = 10 }
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
file sealed class FixedEnumerator(IReadOnlyList<FileSystemItem> items) : IFileSystemEnumerator
|
||||
{
|
||||
public IEnumerable<FileSystemItem> EnumerateChildren(string directoryPath)
|
||||
=> EnumerateChildrenSafe(directoryPath, out _);
|
||||
|
||||
public FileSystemItem? GetItem(string path) => throw new InvalidOperationException("GetItem must not run during folder listing.");
|
||||
|
||||
public IReadOnlyList<FileSystemItem> EnumerateChildrenSafe(string directoryPath, out string? error)
|
||||
{
|
||||
error = null;
|
||||
return items;
|
||||
}
|
||||
}
|
||||
|
||||
file sealed class GatedCloudProvider(string root, TaskCompletionSource gate) : IStorageProvider
|
||||
{
|
||||
public int CompletedCalls;
|
||||
|
||||
public ProviderManifest Manifest { get; } = new("onedrive", "OneDrive", "1", ProviderIsolation.InProcess);
|
||||
public ProviderCapability GetCapabilities() => ProviderCapability.CloudState;
|
||||
public bool TryMatchRoot(string path) => path.StartsWith(root.TrimEnd('\\'), StringComparison.OrdinalIgnoreCase);
|
||||
public IReadOnlyList<ProviderPlace> GetPlaces() => [];
|
||||
|
||||
public async Task<IReadOnlyList<ProviderItemState>> GetItemStatesAsync(
|
||||
IReadOnlyList<string> paths,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await gate.Task.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
Interlocked.Increment(ref CompletedCalls);
|
||||
return paths.Select(p => new ProviderItemState(
|
||||
"onedrive",
|
||||
p,
|
||||
Plugin.Abstractions.CloudAvailability.OnlineOnly,
|
||||
10,
|
||||
0,
|
||||
true,
|
||||
true,
|
||||
"Online-only",
|
||||
null)).ToList();
|
||||
}
|
||||
|
||||
public Task<ProviderActionResult> TryInvokeAsync(ProviderActionRequest request, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(new ProviderActionResult(ProviderActionStatus.Unsupported));
|
||||
|
||||
public Task<ProviderQuota?> TryGetQuotaAsync(string rootPath, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<ProviderQuota?>(null);
|
||||
}
|
||||
|
||||
file sealed class RecordingProvider(string root) : IStorageProvider
|
||||
{
|
||||
public List<int> BatchSizes { get; } = [];
|
||||
|
||||
public ProviderManifest Manifest { get; } = new("onedrive", "OneDrive", "1", ProviderIsolation.InProcess);
|
||||
public ProviderCapability GetCapabilities() => ProviderCapability.CloudState;
|
||||
public bool TryMatchRoot(string path) => path.StartsWith(root.TrimEnd('\\'), StringComparison.OrdinalIgnoreCase);
|
||||
public IReadOnlyList<ProviderPlace> GetPlaces() => [];
|
||||
|
||||
public Task<IReadOnlyList<ProviderItemState>> GetItemStatesAsync(
|
||||
IReadOnlyList<string> paths,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
BatchSizes.Add(paths.Count);
|
||||
return Task.FromResult<IReadOnlyList<ProviderItemState>>(
|
||||
paths.Select(p => new ProviderItemState(
|
||||
"onedrive",
|
||||
p,
|
||||
Plugin.Abstractions.CloudAvailability.LocallyAvailable,
|
||||
1,
|
||||
1,
|
||||
false,
|
||||
false,
|
||||
null,
|
||||
null)).ToList());
|
||||
}
|
||||
|
||||
public Task<ProviderActionResult> TryInvokeAsync(ProviderActionRequest request, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(new ProviderActionResult(ProviderActionStatus.Unsupported));
|
||||
|
||||
public Task<ProviderQuota?> TryGetQuotaAsync(string rootPath, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<ProviderQuota?>(null);
|
||||
}
|
||||
|
||||
file sealed class HydrationVolumes : IVolumeService
|
||||
{
|
||||
public List<VolumeFingerprint> Online { get; set; } = [];
|
||||
public IReadOnlyList<VolumeFingerprint> EnumerateOnlineVolumes() => Online;
|
||||
public VolumeFingerprint? Probe(string path)
|
||||
=> Online.FirstOrDefault(v => path.StartsWith(v.RootPath.TrimEnd('\\'), StringComparison.OrdinalIgnoreCase));
|
||||
public bool IsPathReachable(string path)
|
||||
=> Online.Any(v => path.StartsWith(v.RootPath.TrimEnd('\\'), StringComparison.OrdinalIgnoreCase));
|
||||
public VolumeSpace GetSpace(string path)
|
||||
{
|
||||
var fp = Probe(path);
|
||||
return new VolumeSpace(fp?.CapacityBytes, fp?.FreeBytes);
|
||||
}
|
||||
}
|
||||
|
||||
file sealed class HydrationEnv : IAppEnvironment
|
||||
{
|
||||
public HydrationEnv(string dir)
|
||||
{
|
||||
DataDirectory = dir;
|
||||
Directory.CreateDirectory(dir);
|
||||
DatabasePath = Path.Combine(dir, "index.db");
|
||||
LogDirectory = Path.Combine(dir, "logs");
|
||||
Directory.CreateDirectory(LogDirectory);
|
||||
}
|
||||
|
||||
public string DataDirectory { get; }
|
||||
public string DatabasePath { get; }
|
||||
public string LogDirectory { get; }
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
using Explorer.Application;
|
||||
using Explorer.Domain;
|
||||
using Explorer.Domain.Abstractions;
|
||||
using Explorer.Plugin.Abstractions;
|
||||
using Explorer.Storage.Sqlite;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
@@ -28,11 +29,61 @@ public class BrowseServiceTests
|
||||
await store.Entries.UpsertAsync(root);
|
||||
|
||||
var listing = await browse.ListThisPcAsync();
|
||||
var item = Assert.Single(listing.Items);
|
||||
var item = Assert.Single(listing.Items, i => i.FullPath.StartsWith(@"C:\", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.Equal(1_073_741_824, item.SizeBytes);
|
||||
Assert.Equal(250_000_000, item.FreeSpaceBytes);
|
||||
Assert.Equal(1_000_000_000, item.CapacityBytes);
|
||||
Assert.True(item.IsDirectory);
|
||||
Assert.Contains(listing.Items, i => i.FullPath == LocationRoots.RecycleBin);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task This_pc_lists_untracked_network_drives_as_importable()
|
||||
{
|
||||
var db = Path.Combine(Path.GetTempPath(), "ew-browse", Guid.NewGuid().ToString("N"), "index.db");
|
||||
var store = new SqliteIndexStore(db, NullLogger<SqliteIndexStore>.Instance);
|
||||
await store.OpenAsync();
|
||||
var volumes = new BrowseVolumes
|
||||
{
|
||||
Online =
|
||||
[
|
||||
new VolumeFingerprint
|
||||
{
|
||||
Kind = SourceKind.NtfsLocal,
|
||||
RootPath = @"C:\",
|
||||
DisplayName = "C:",
|
||||
VolumeSerial = 1,
|
||||
CapacityBytes = 1_000_000_000,
|
||||
FreeBytes = 250_000_000
|
||||
},
|
||||
new VolumeFingerprint
|
||||
{
|
||||
Kind = SourceKind.Smb,
|
||||
RootPath = @"Z:\",
|
||||
DisplayName = "Z: (Network)",
|
||||
Filesystem = "SMB",
|
||||
CapacityBytes = 2_000,
|
||||
FreeBytes = 500
|
||||
}
|
||||
]
|
||||
};
|
||||
var env = new BrowseEnv(Path.GetDirectoryName(db)!);
|
||||
var sources = new SourceManager(store, volumes, env, new SystemClock(), NullLogger<SourceManager>.Instance);
|
||||
await sources.InitializeAsync();
|
||||
var browse = new BrowseService(
|
||||
new BrowseEnumerator(),
|
||||
volumes,
|
||||
store,
|
||||
sources,
|
||||
new StorageProviderRegistry([], NullLogger<StorageProviderRegistry>.Instance),
|
||||
new CloudPlaceStore(env),
|
||||
new UiPreferencesStore(env));
|
||||
var listing = await browse.ListThisPcAsync();
|
||||
Assert.Contains(listing.Items, i => i.FullPath.StartsWith(@"C:\", StringComparison.OrdinalIgnoreCase) && !i.AvailableToImport);
|
||||
var imported = Assert.Single(listing.Items, i => i.AvailableToImport);
|
||||
Assert.Equal(@"Z:\", imported.FullPath);
|
||||
Assert.Contains("(Windows)", imported.DisplayName);
|
||||
await store.DisposeAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -72,7 +123,95 @@ public class BrowseServiceTests
|
||||
Assert.Equal(10, loose.SizeBytes);
|
||||
}
|
||||
|
||||
private static async Task<(BrowseService Browse, SqliteIndexStore Store)> CreateAsync(string root, string display)
|
||||
[Fact]
|
||||
public async Task Recycle_bin_listing_uses_catalog_summary()
|
||||
{
|
||||
var (browse, _) = await CreateAsync(@"C:\", "C:", new FakeRecycleBin(new RecycleBinSummary(3, 4096)));
|
||||
var listing = browse.ListRecycleBin();
|
||||
Assert.Equal(LocationRoots.RecycleBin, listing.Path);
|
||||
Assert.Contains("3", listing.Error);
|
||||
Assert.Contains("4 KB", listing.Error, StringComparison.OrdinalIgnoreCase);
|
||||
var item = Assert.Single(listing.Items);
|
||||
Assert.True(item.Location.IsRecycleBin);
|
||||
Assert.Equal(4096, item.SizeBytes);
|
||||
|
||||
var redirected = await browse.ListAsync(@"C:\$RECYCLE.BIN");
|
||||
Assert.Equal(LocationRoots.RecycleBin, redirected.Path);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Recycle_bin_folder_stays_hidden_when_protected_locations_are_shown()
|
||||
{
|
||||
var (browse, _) = await CreateAsync(
|
||||
@"C:\",
|
||||
"C:",
|
||||
enumerator: new RecycleFolderEnumerator(),
|
||||
configurePrefs: store => store.Save(UiPreferences.Default with { ShowProtectedSystemLocations = true }));
|
||||
var listing = await browse.ListAsync(@"C:\");
|
||||
Assert.DoesNotContain(listing.Items, i => i.Name.Equals("$RECYCLE.BIN", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.Contains(listing.Items, i => i.Name == "Movies");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Cloud_quota_overrides_volume_space_and_null_is_safe()
|
||||
{
|
||||
var db = Path.Combine(Path.GetTempPath(), "ew-browse", Guid.NewGuid().ToString("N"), "index.db");
|
||||
var store = new SqliteIndexStore(db, NullLogger<SqliteIndexStore>.Instance);
|
||||
await store.OpenAsync();
|
||||
var env = new BrowseEnv(Path.GetDirectoryName(db)!);
|
||||
var volumes = new BrowseVolumes();
|
||||
var sources = new SourceManager(store, volumes, env, new SystemClock(), NullLogger<SourceManager>.Instance);
|
||||
await sources.InitializeAsync();
|
||||
var places = new CloudPlaceStore(env);
|
||||
var cloudRoot = Path.Combine(env.DataDirectory, "OneDrive");
|
||||
Directory.CreateDirectory(cloudRoot);
|
||||
places.Add("onedrive", cloudRoot, "OneDrive");
|
||||
var quotaProvider = new QuotaProvider(cloudRoot, new ProviderQuota(cloudRoot, UsedBytes: 20, TotalBytes: 100, Label: "OneDrive"));
|
||||
var browse = new BrowseService(
|
||||
new BrowseEnumerator(),
|
||||
volumes,
|
||||
store,
|
||||
sources,
|
||||
new StorageProviderRegistry([quotaProvider], NullLogger<StorageProviderRegistry>.Instance),
|
||||
places,
|
||||
new UiPreferencesStore(env));
|
||||
var listing = await browse.ListCloudAsync();
|
||||
var item = Assert.Single(listing.Items);
|
||||
Assert.Equal(80, item.FreeSpaceBytes);
|
||||
Assert.Equal(100, item.CapacityBytes);
|
||||
|
||||
var nullSafe = new BrowseService(
|
||||
new BrowseEnumerator(),
|
||||
volumes,
|
||||
store,
|
||||
sources,
|
||||
new StorageProviderRegistry([new QuotaProvider(cloudRoot, null)], NullLogger<StorageProviderRegistry>.Instance),
|
||||
places,
|
||||
new UiPreferencesStore(env));
|
||||
var fallback = await nullSafe.ListCloudAsync();
|
||||
Assert.Null(Assert.Single(fallback.Items).FreeSpaceBytes);
|
||||
|
||||
var throwing = new BrowseService(
|
||||
new BrowseEnumerator(),
|
||||
volumes,
|
||||
store,
|
||||
sources,
|
||||
new StorageProviderRegistry(
|
||||
[new QuotaProvider(cloudRoot, null, throwOnQuota: true)],
|
||||
NullLogger<StorageProviderRegistry>.Instance),
|
||||
places,
|
||||
new UiPreferencesStore(env));
|
||||
var safe = await throwing.ListCloudAsync();
|
||||
Assert.Null(Assert.Single(safe.Items).FreeSpaceBytes);
|
||||
await store.DisposeAsync();
|
||||
}
|
||||
|
||||
private static async Task<(BrowseService Browse, SqliteIndexStore Store)> CreateAsync(
|
||||
string root,
|
||||
string display,
|
||||
IRecycleBinCatalog? recycle = null,
|
||||
IFileSystemEnumerator? enumerator = null,
|
||||
Action<UiPreferencesStore>? configurePrefs = null)
|
||||
{
|
||||
var db = Path.Combine(Path.GetTempPath(), "ew-browse", Guid.NewGuid().ToString("N"), "index.db");
|
||||
var store = new SqliteIndexStore(db, NullLogger<SqliteIndexStore>.Instance);
|
||||
@@ -95,14 +234,17 @@ public class BrowseServiceTests
|
||||
var env = new BrowseEnv(Path.GetDirectoryName(db)!);
|
||||
var sources = new SourceManager(store, volumes, env, new SystemClock(), NullLogger<SourceManager>.Instance);
|
||||
await sources.InitializeAsync();
|
||||
var prefs = new UiPreferencesStore(env);
|
||||
configurePrefs?.Invoke(prefs);
|
||||
var browse = new BrowseService(
|
||||
new BrowseEnumerator(),
|
||||
enumerator ?? new BrowseEnumerator(),
|
||||
volumes,
|
||||
store,
|
||||
sources,
|
||||
new StorageProviderRegistry([], NullLogger<StorageProviderRegistry>.Instance),
|
||||
new CloudPlaceStore(env),
|
||||
new UiPreferencesStore(env));
|
||||
prefs,
|
||||
recycle: recycle);
|
||||
return (browse, store);
|
||||
}
|
||||
}
|
||||
@@ -125,6 +267,50 @@ file sealed class BrowseEnumerator : IFileSystemEnumerator
|
||||
}
|
||||
}
|
||||
|
||||
file sealed class RecycleFolderEnumerator : IFileSystemEnumerator
|
||||
{
|
||||
public IEnumerable<FileSystemItem> EnumerateChildren(string directoryPath)
|
||||
=> EnumerateChildrenSafe(directoryPath, out _);
|
||||
|
||||
public FileSystemItem? GetItem(string path) => null;
|
||||
|
||||
public IReadOnlyList<FileSystemItem> EnumerateChildrenSafe(string directoryPath, out string? error)
|
||||
{
|
||||
error = null;
|
||||
return
|
||||
[
|
||||
new FileSystemItem
|
||||
{
|
||||
FullPath = Path.Combine(directoryPath, "$RECYCLE.BIN"),
|
||||
Name = "$RECYCLE.BIN",
|
||||
IsDirectory = true,
|
||||
Attributes = AttributeFlags.Directory | AttributeFlags.Hidden | AttributeFlags.System
|
||||
},
|
||||
new FileSystemItem { FullPath = Path.Combine(directoryPath, "Movies"), Name = "Movies", IsDirectory = true }
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
file sealed class FakeRecycleBin(RecycleBinSummary summary) : IRecycleBinCatalog
|
||||
{
|
||||
public RecycleBinSummary? TrySummarize(string? rootPath = null) => summary;
|
||||
}
|
||||
|
||||
file sealed class QuotaProvider(string root, ProviderQuota? quota, bool throwOnQuota = false) : IStorageProvider
|
||||
{
|
||||
public ProviderManifest Manifest { get; } = new("onedrive", "OneDrive", "1", ProviderIsolation.InProcess);
|
||||
public ProviderCapability GetCapabilities() => ProviderCapability.Quota;
|
||||
public bool TryMatchRoot(string path) => CloudPath.IsUnder(root, path);
|
||||
public Task<IReadOnlyList<ProviderItemState>> GetItemStatesAsync(IReadOnlyList<string> paths, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<IReadOnlyList<ProviderItemState>>([]);
|
||||
public Task<ProviderActionResult> TryInvokeAsync(ProviderActionRequest request, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(new ProviderActionResult(ProviderActionStatus.Unsupported));
|
||||
public Task<ProviderQuota?> TryGetQuotaAsync(string rootPath, CancellationToken cancellationToken = default)
|
||||
=> throwOnQuota
|
||||
? throw new InvalidOperationException("quota failed")
|
||||
: Task.FromResult(quota);
|
||||
}
|
||||
|
||||
file sealed class BrowseVolumes : IVolumeService
|
||||
{
|
||||
public List<VolumeFingerprint> Online { get; set; } = [];
|
||||
|
||||
@@ -10,6 +10,13 @@ namespace Explorer.Application.Tests;
|
||||
|
||||
public class CloudProviderTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Quota_returns_null_when_provider_throws()
|
||||
{
|
||||
var registry = new StorageProviderRegistry([new QuotaThrowingProvider()], NullLogger<StorageProviderRegistry>.Instance);
|
||||
Assert.Null(await registry.TryGetQuotaAsync(@"C:\OneDrive"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Enrich_passes_through_when_provider_throws()
|
||||
{
|
||||
@@ -246,6 +253,19 @@ file sealed class ThrowingProvider : IStorageProvider
|
||||
=> throw new InvalidOperationException("quota failed");
|
||||
}
|
||||
|
||||
file sealed class QuotaThrowingProvider : IStorageProvider
|
||||
{
|
||||
public ProviderManifest Manifest { get; } = new("onedrive", "OneDrive", "1", ProviderIsolation.InProcess);
|
||||
public ProviderCapability GetCapabilities() => ProviderCapability.Quota;
|
||||
public bool TryMatchRoot(string path) => path.Contains("OneDrive", StringComparison.OrdinalIgnoreCase);
|
||||
public Task<IReadOnlyList<ProviderItemState>> GetItemStatesAsync(IReadOnlyList<string> paths, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<IReadOnlyList<ProviderItemState>>([]);
|
||||
public Task<ProviderActionResult> TryInvokeAsync(ProviderActionRequest request, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(new ProviderActionResult(ProviderActionStatus.Unsupported));
|
||||
public Task<ProviderQuota?> TryGetQuotaAsync(string rootPath, CancellationToken cancellationToken = default)
|
||||
=> throw new InvalidOperationException("quota failed");
|
||||
}
|
||||
|
||||
file sealed class StubProvider : IStorageProvider
|
||||
{
|
||||
public ProviderManifest Manifest { get; } = new("onedrive", "OneDrive", "1", ProviderIsolation.InProcess);
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
using Explorer.Application;
|
||||
using Explorer.Domain;
|
||||
using Explorer.Domain.Abstractions;
|
||||
|
||||
namespace Explorer.Application.Tests;
|
||||
|
||||
public class FileOperationProfilePlannerTests
|
||||
{
|
||||
private static readonly DateTimeOffset T0 = DateTimeOffset.Parse("2024-06-01T12:00:00Z");
|
||||
|
||||
[Fact]
|
||||
public void Copy_lists_copy_operations_only()
|
||||
{
|
||||
var fs = Tree()
|
||||
.Dir(@"C:\src")
|
||||
.File(@"C:\src\a.txt", 1, T0)
|
||||
.File(@"C:\src\b.txt", 2, T0)
|
||||
.Dir(@"C:\dst");
|
||||
|
||||
var plan = Build(CopyProfile(), fs, [@"C:\src"]);
|
||||
Assert.True(plan.CanEnqueue);
|
||||
Assert.All(plan.Operations, o => Assert.Equal(TransferOp.Copy, o.Op));
|
||||
Assert.Contains(plan.Operations, o => o.SourcePath == @"C:\src\a.txt" && o.DestinationPath == @"C:\dst\src\a.txt");
|
||||
Assert.Contains(plan.Operations, o => o.SourcePath == @"C:\src\b.txt" && o.DestinationPath == @"C:\dst\src\b.txt");
|
||||
Assert.Contains(plan.ProfilePreview, r => r.Action == "Copy" && r.Path == @"C:\dst\src\a.txt");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Compress_lists_one_archive_job()
|
||||
{
|
||||
var fs = Tree()
|
||||
.Dir(@"C:\src")
|
||||
.File(@"C:\src\a.txt", 1, T0)
|
||||
.File(@"C:\src\b.txt", 2, T0)
|
||||
.Dir(@"C:\dst");
|
||||
|
||||
var plan = Build(new OperationProfile
|
||||
{
|
||||
Name = "Archive",
|
||||
DestPath = @"C:\dst",
|
||||
DoCompress = true
|
||||
}, fs, [@"C:\src"]);
|
||||
Assert.True(plan.CanEnqueue);
|
||||
var compress = Assert.Single(plan.Operations);
|
||||
Assert.Equal(TransferOp.Compress, compress.Op);
|
||||
Assert.Equal(@"C:\src\a.txt|C:\src\b.txt", compress.SourcePath);
|
||||
Assert.Equal(@"C:\dst\src.7z", compress.DestinationPath);
|
||||
Assert.Contains(plan.ProfilePreview, r => r.Action == "Compress");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dirty_git_stops_before_operations()
|
||||
{
|
||||
var fs = Tree()
|
||||
.Dir(@"C:\src")
|
||||
.File(@"C:\src\a.txt", 1, T0)
|
||||
.Dir(@"C:\dst");
|
||||
var git = new GitStatus { RepoRoot = @"C:\src", Branch = "main", ModifiedCount = 1 };
|
||||
|
||||
var plan = Build(CopyProfile(git: true), fs, [@"C:\src"], git);
|
||||
Assert.False(plan.CanEnqueue);
|
||||
Assert.Empty(plan.Operations);
|
||||
Assert.Contains(plan.Issues, i => i.Message == "Working tree is not clean.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Missing_git_and_missing_repo_are_errors()
|
||||
{
|
||||
var fs = Tree().Dir(@"C:\src").File(@"C:\src\a.txt", 1, T0).Dir(@"C:\dst");
|
||||
var noGit = Build(CopyProfile(git: true), fs, [@"C:\src"], gitAvailable: false);
|
||||
Assert.Contains(noGit.Issues, i => i.Message == "git.exe is not available.");
|
||||
|
||||
var noRepo = Build(CopyProfile(git: true), fs, [@"C:\src"], git: null);
|
||||
Assert.Contains(noRepo.Issues, i => i.Message == "Source is not a Git repository.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Offline_destination_is_an_error()
|
||||
{
|
||||
var fs = Tree()
|
||||
.Dir(@"C:\src")
|
||||
.File(@"C:\src\a.txt", 1, T0)
|
||||
.Dir(@"Z:\backup");
|
||||
|
||||
var plan = Build(
|
||||
CopyProfile(@"Z:\backup"),
|
||||
fs,
|
||||
[@"C:\src"],
|
||||
reachable: 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 Rename_then_copy_uses_new_names()
|
||||
{
|
||||
var fs = Tree()
|
||||
.Dir(@"C:\src")
|
||||
.File(@"C:\src\keep.txt", 1, T0)
|
||||
.File(@"C:\src\a.txt", 1, T0)
|
||||
.Dir(@"C:\dst");
|
||||
|
||||
var plan = Build(new OperationProfile
|
||||
{
|
||||
Name = "Rename copy",
|
||||
DestPath = @"C:\dst",
|
||||
DoCopy = true,
|
||||
DoRename = true,
|
||||
RenamePrefix = "x_"
|
||||
}, fs, [@"C:\src"]);
|
||||
Assert.True(plan.CanEnqueue);
|
||||
Assert.Contains(plan.Operations, o => o.Op == TransferOp.Rename && o.NewName == "x_keep.txt");
|
||||
Assert.Contains(plan.Operations, o =>
|
||||
o.Op == TransferOp.Copy
|
||||
&& o.SourcePath == @"C:\src\x_a.txt"
|
||||
&& o.DestinationPath == @"C:\dst\src\x_a.txt");
|
||||
Assert.Contains(plan.Operations, o =>
|
||||
o.Op == TransferOp.Copy
|
||||
&& o.SourcePath == @"C:\src\x_keep.txt"
|
||||
&& o.DestinationPath == @"C:\dst\src\x_keep.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)
|
||||
.Dir(@"C:\src\bin")
|
||||
.File(@"C:\src\cloud.bin", 1, T0)
|
||||
.Dir(@"C:\dst");
|
||||
|
||||
var plan = Build(new OperationProfile
|
||||
{
|
||||
Name = "Copy",
|
||||
DestPath = @"C:\dst",
|
||||
DoCopy = true,
|
||||
Excludes = "*.tmp\nbin"
|
||||
}, fs, [@"C:\src"], wouldHydrate: 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.Contains(@"\bin", 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 Missing_7zip_is_an_error()
|
||||
{
|
||||
var fs = Tree().Dir(@"C:\src").File(@"C:\src\a.txt", 1, T0).Dir(@"C:\dst");
|
||||
var plan = Build(new OperationProfile
|
||||
{
|
||||
Name = "Archive",
|
||||
DestPath = @"C:\dst",
|
||||
DoCompress = true
|
||||
}, fs, [@"C:\src"], compressAvailable: false);
|
||||
Assert.False(plan.CanEnqueue);
|
||||
Assert.Contains(plan.Issues, i => i.Message.Contains("7-Zip", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private static OperationProfile CopyProfile(string dest = @"C:\dst", bool git = false)
|
||||
=> new()
|
||||
{
|
||||
Name = "Copy",
|
||||
DestPath = dest,
|
||||
DoCopy = true,
|
||||
RequireGitClean = git
|
||||
};
|
||||
|
||||
private static OperationPlan Build(
|
||||
OperationProfile profile,
|
||||
IFileSystemEnumerator fs,
|
||||
string[] sources,
|
||||
GitStatus? git = null,
|
||||
bool gitAvailable = true,
|
||||
bool compressAvailable = true,
|
||||
Func<string, bool>? reachable = null,
|
||||
Func<FileSystemItem, bool>? wouldHydrate = null)
|
||||
=> new FileOperationProfilePlanner(new RenamePlanner()).Build(
|
||||
profile,
|
||||
sources,
|
||||
fs,
|
||||
reachable ?? (_ => true),
|
||||
git,
|
||||
gitAvailable,
|
||||
compressAvailable,
|
||||
SevenZipLocator.MissingHint,
|
||||
pathExists: _ => false,
|
||||
wouldHydrate: wouldHydrate);
|
||||
|
||||
private static TreeEnumerator Tree() => new();
|
||||
}
|
||||
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('\\');
|
||||
}
|
||||
98
tests/Explorer.Application.Tests/GitPorcelainParserTests.cs
Normal file
98
tests/Explorer.Application.Tests/GitPorcelainParserTests.cs
Normal file
@@ -0,0 +1,98 @@
|
||||
using Explorer.Application;
|
||||
using Explorer.Domain;
|
||||
|
||||
namespace Explorer.Application.Tests;
|
||||
|
||||
public class GitPorcelainParserTests
|
||||
{
|
||||
[Fact]
|
||||
public void Parses_branch_counts_and_ahead_behind()
|
||||
{
|
||||
var status = GitPorcelainParser.Parse("""
|
||||
# branch.oid abcdef1234567890
|
||||
# branch.head main
|
||||
# branch.upstream origin/main
|
||||
# branch.ab +2 -1
|
||||
1 .M N... 100644 100644 100644 a a src/App.cs
|
||||
1 M. N... 100644 100644 100644 b b README.md
|
||||
2 R. N... 100644 100644 100644 c c R100 old.txt new.txt
|
||||
? bin/out.dll
|
||||
? notes.md
|
||||
! ignore.me
|
||||
""", @"C:\src");
|
||||
Assert.NotNull(status);
|
||||
Assert.Equal("main", status.Branch);
|
||||
Assert.Equal(3, status.ModifiedCount);
|
||||
Assert.Equal(2, status.UntrackedCount);
|
||||
Assert.Equal(2, status.Ahead);
|
||||
Assert.Equal(1, status.Behind);
|
||||
Assert.False(status.WorkingTreeClean);
|
||||
Assert.Equal("main · 3 modified · 2 untracked · 2 ahead · 1 behind", status.Badge);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Clean_tree_uses_detached_oid_prefix()
|
||||
{
|
||||
var status = GitPorcelainParser.Parse("""
|
||||
# branch.oid 0123456789abcdef
|
||||
# branch.head (detached)
|
||||
""", @"F:\repo");
|
||||
Assert.NotNull(status);
|
||||
Assert.Equal("0123456", status.Branch);
|
||||
Assert.True(status.WorkingTreeClean);
|
||||
Assert.Equal("0123456 · clean", status.Badge);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Empty_output_is_null()
|
||||
=> Assert.Null(GitPorcelainParser.Parse("", @"C:\src"));
|
||||
}
|
||||
|
||||
public class GitLocatorTests
|
||||
{
|
||||
[Fact]
|
||||
public void Prefers_the_configured_path_when_it_exists()
|
||||
{
|
||||
var path = @"C:\Tools\git.exe";
|
||||
Assert.Equal(path, GitLocator.Find(path, fileExists: p => p == path, pathVariable: ""));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Finds_git_on_PATH()
|
||||
{
|
||||
var found = GitLocator.Find(
|
||||
null,
|
||||
fileExists: p => p.Equals(@"D:\bin\git.exe", StringComparison.OrdinalIgnoreCase),
|
||||
pathVariable: @"C:\Windows;D:\bin");
|
||||
Assert.Equal(@"D:\bin\git.exe", found);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Returns_null_when_git_is_missing()
|
||||
=> Assert.Null(GitLocator.Find(null, fileExists: _ => false, pathVariable: @"C:\none"));
|
||||
}
|
||||
|
||||
public class GitRepoDetectorTests
|
||||
{
|
||||
[Fact]
|
||||
public void Finds_the_repo_root_from_a_nested_path()
|
||||
{
|
||||
var dirs = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { @"C:\src", @"C:\src\.git", @"C:\src\app" };
|
||||
var files = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
Assert.Equal(@"C:\src", GitRepoDetector.FindRoot(@"C:\src\app\Main.cs", dirs.Contains, files.Contains));
|
||||
Assert.True(GitRepoDetector.IsRepoRoot(@"C:\src", dirs.Contains, files.Contains));
|
||||
Assert.False(GitRepoDetector.IsRepoRoot(@"C:\src\app", dirs.Contains, files.Contains));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Treats_a_gitfile_as_a_worktree_root()
|
||||
{
|
||||
var dirs = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { @"C:\work" };
|
||||
var files = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { @"C:\work\.git" };
|
||||
Assert.Equal(@"C:\work", GitRepoDetector.FindRoot(@"C:\work", dirs.Contains, files.Contains));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Virtual_roots_are_not_repos()
|
||||
=> Assert.Null(GitRepoDetector.FindRoot(LocationRoots.ThisPc, _ => true, _ => true));
|
||||
}
|
||||
69
tests/Explorer.Application.Tests/MarkdownParserTests.cs
Normal file
69
tests/Explorer.Application.Tests/MarkdownParserTests.cs
Normal file
@@ -0,0 +1,69 @@
|
||||
using Explorer.Application;
|
||||
|
||||
namespace Explorer.Application.Tests;
|
||||
|
||||
public class MarkdownParserTests
|
||||
{
|
||||
[Fact]
|
||||
public void Parses_headings_lists_and_tables()
|
||||
{
|
||||
var doc = MarkdownParser.Parse("""
|
||||
# Title
|
||||
|
||||
Intro paragraph.
|
||||
|
||||
## Locations
|
||||
|
||||
- One
|
||||
- Two
|
||||
|
||||
| A | B |
|
||||
| --- | --- |
|
||||
| 1 | 2 |
|
||||
""");
|
||||
Assert.Contains(doc.Blocks, b => b is MarkdownHeading h && h.Level == 1 && h.Text == "Title");
|
||||
Assert.Equal("Locations", Assert.Single(doc.Headings).Text);
|
||||
var list = Assert.IsType<MarkdownList>(doc.Blocks.First(b => b is MarkdownList));
|
||||
Assert.Equal(["One", "Two"], list.Items);
|
||||
var table = Assert.IsType<MarkdownTable>(doc.Blocks.First(b => b is MarkdownTable));
|
||||
Assert.Equal(["A", "B"], table.Headers);
|
||||
Assert.Equal(["1", "2"], Assert.Single(table.Rows));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parses_fenced_code()
|
||||
{
|
||||
var doc = MarkdownParser.Parse("""
|
||||
```powershell
|
||||
dotnet build
|
||||
```
|
||||
""");
|
||||
var code = Assert.IsType<MarkdownCode>(Assert.Single(doc.Blocks));
|
||||
Assert.Equal("powershell", code.Language);
|
||||
Assert.Equal("dotnet build", code.Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void User_guide_in_the_repo_parses()
|
||||
{
|
||||
var dir = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
string? path = null;
|
||||
while (dir is not null)
|
||||
{
|
||||
var candidate = Path.Combine(dir.FullName, "docs", "Documentation.md");
|
||||
if (File.Exists(candidate))
|
||||
{
|
||||
path = candidate;
|
||||
break;
|
||||
}
|
||||
|
||||
dir = dir.Parent;
|
||||
}
|
||||
|
||||
Assert.NotNull(path);
|
||||
var doc = MarkdownParser.Parse(File.ReadAllText(path));
|
||||
Assert.Contains(doc.Headings, h => h.Text == "Locations");
|
||||
Assert.Contains(doc.Headings, h => h.Text == "File Operations Queue");
|
||||
Assert.Contains(doc.Blocks, b => b is MarkdownTable);
|
||||
}
|
||||
}
|
||||
187
tests/Explorer.Application.Tests/RenamePlannerTests.cs
Normal file
187
tests/Explorer.Application.Tests/RenamePlannerTests.cs
Normal file
@@ -0,0 +1,187 @@
|
||||
using Explorer.Application;
|
||||
using Explorer.Domain;
|
||||
|
||||
namespace Explorer.Application.Tests;
|
||||
|
||||
public class RenamePlannerTests
|
||||
{
|
||||
private static RenameSubject File(string path)
|
||||
=> new(path, PathRules.GetFileName(path), false);
|
||||
|
||||
[Fact]
|
||||
public void Search_replace_prefix_suffix_and_counter()
|
||||
{
|
||||
var plan = new RenamePlanner().Build(
|
||||
[File(@"C:\a\Vacation.jpg"), File(@"C:\a\Vacation (2).jpg")],
|
||||
new RenameRuleSet
|
||||
{
|
||||
Search = "Vacation",
|
||||
Replace = "Trip",
|
||||
Prefix = "2024_",
|
||||
Suffix = "_ok",
|
||||
UseCounter = true,
|
||||
CounterStart = 1,
|
||||
CounterPadding = 2
|
||||
});
|
||||
Assert.True(plan.CanEnqueue);
|
||||
Assert.Equal("2024_Trip_ok01.jpg", plan.Preview[0].NewName);
|
||||
Assert.Equal("2024_Trip (2)_ok02.jpg", plan.Preview[1].NewName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Regex_replace_and_case_and_extension()
|
||||
{
|
||||
var plan = new RenamePlanner().Build(
|
||||
[File(@"C:\a\IMG_001.JPG")],
|
||||
new RenameRuleSet
|
||||
{
|
||||
Search = @"IMG_(\d+)",
|
||||
Replace = "Photo_$1",
|
||||
UseRegex = true,
|
||||
CaseMode = RenameCaseMode.Lower,
|
||||
ChangeExtension = true,
|
||||
NewExtension = "jpg"
|
||||
});
|
||||
Assert.Equal("photo_001.jpg", plan.Preview[0].NewName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Counter_placeholder_does_not_need_hydration()
|
||||
{
|
||||
var plan = new RenamePlanner().Build(
|
||||
[File(@"C:\a\clip.mp4")],
|
||||
new RenameRuleSet { Prefix = "Clip_{Counter}_", UseCounter = true, CounterPadding = 3 });
|
||||
Assert.Equal("Clip_001_clip.mp4", plan.Preview[0].NewName);
|
||||
Assert.Contains("{Width}", new RenamePlanner().Build(
|
||||
[File(@"C:\a\clip.mp4")],
|
||||
new RenameRuleSet { Prefix = "{Width}_" }).Preview[0].NewName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Invalid_windows_name_is_an_error()
|
||||
{
|
||||
var plan = new RenamePlanner().Build(
|
||||
[File(@"C:\a\notes.txt")],
|
||||
new RenameRuleSet { Search = "notes", Replace = "CON" });
|
||||
Assert.False(plan.CanEnqueue);
|
||||
Assert.Contains(plan.Issues, i => i.Severity == PlanIssueSeverity.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Collision_when_two_files_map_to_the_same_name()
|
||||
{
|
||||
var plan = new RenamePlanner().Build(
|
||||
[File(@"C:\a\alpha.txt"), File(@"C:\a\beta.txt")],
|
||||
new RenameRuleSet { UseRegex = true, Search = ".+", Replace = "same" });
|
||||
Assert.False(plan.CanEnqueue);
|
||||
Assert.Contains(plan.Issues, i => i.Message.Contains("same name", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Existing_path_outside_the_batch_is_a_collision()
|
||||
{
|
||||
var plan = new RenamePlanner().Build(
|
||||
[File(@"C:\a\one.txt")],
|
||||
new RenameRuleSet { Search = "one", Replace = "held" },
|
||||
path => path.Equals(@"C:\a\held.txt", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.False(plan.CanEnqueue);
|
||||
Assert.Contains(plan.Issues, i => i.Message.Contains("already exists", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Collision_with_an_unchanged_file_in_the_batch()
|
||||
{
|
||||
var plan = new RenamePlanner().Build(
|
||||
[File(@"C:\a\one.txt"), File(@"C:\a\held.txt")],
|
||||
new RenameRuleSet { Search = "one", Replace = "held" });
|
||||
Assert.False(plan.CanEnqueue);
|
||||
Assert.Contains(plan.Issues, i => i.Message.Contains("already exists", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Chain_is_ordered_so_the_destination_moves_first()
|
||||
{
|
||||
var issues = new List<PlanIssue>();
|
||||
var ordered = RenamePlanner.Order(
|
||||
[
|
||||
(@"C:\a\a.txt", @"C:\a\b.txt", "b.txt"),
|
||||
(@"C:\a\b.txt", @"C:\a\c.txt", "c.txt")
|
||||
],
|
||||
issues);
|
||||
Assert.Empty(issues);
|
||||
Assert.Equal(@"C:\a\b.txt", ordered[0].Source);
|
||||
Assert.Equal(@"C:\a\a.txt", ordered[1].Source);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Cycle_is_an_error()
|
||||
{
|
||||
var issues = new List<PlanIssue>();
|
||||
RenamePlanner.Order(
|
||||
[
|
||||
(@"C:\a\x.txt", @"C:\a\y.txt", "y.txt"),
|
||||
(@"C:\a\y.txt", @"C:\a\x.txt", "x.txt")
|
||||
],
|
||||
issues);
|
||||
Assert.Contains(issues, i => i.Message.Contains("cycle", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Undo_restores_names_when_destination_still_matches()
|
||||
{
|
||||
var batch = new RenameBatch
|
||||
{
|
||||
Id = 1,
|
||||
CreatedUtc = DateTimeOffset.UtcNow,
|
||||
Items =
|
||||
[
|
||||
new RenameBatchItem(@"C:\a\old.txt", @"C:\a\new.txt", 0),
|
||||
new RenameBatchItem(@"C:\a\one.txt", @"C:\a\two.txt", 1)
|
||||
]
|
||||
};
|
||||
var exists = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { @"C:\a\new.txt", @"C:\a\two.txt" };
|
||||
var plan = new RenamePlanner().BuildUndo(batch, exists.Contains);
|
||||
Assert.True(plan.CanEnqueue);
|
||||
Assert.Equal(@"C:\a\two.txt", plan.Operations[0].SourcePath);
|
||||
Assert.Equal(@"C:\a\old.txt", plan.Operations[1].DestinationPath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Undo_refuses_when_rename_has_not_finished()
|
||||
{
|
||||
var batch = new RenameBatch
|
||||
{
|
||||
Id = 1,
|
||||
CreatedUtc = DateTimeOffset.UtcNow,
|
||||
Items = [new RenameBatchItem(@"C:\a\old.txt", @"C:\a\new.txt", 0)]
|
||||
};
|
||||
var exists = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { @"C:\a\old.txt" };
|
||||
var plan = new RenamePlanner().BuildUndo(batch, exists.Contains);
|
||||
Assert.True(plan.HasErrors);
|
||||
Assert.Contains(plan.Issues, i => i.Message.Contains("not finished", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Undo_fails_when_original_name_is_taken()
|
||||
{
|
||||
var batch = new RenameBatch
|
||||
{
|
||||
Id = 1,
|
||||
CreatedUtc = DateTimeOffset.UtcNow,
|
||||
Items = [new RenameBatchItem(@"C:\a\old.txt", @"C:\a\new.txt", 0)]
|
||||
};
|
||||
var exists = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { @"C:\a\new.txt", @"C:\a\old.txt" };
|
||||
var plan = new RenamePlanner().BuildUndo(batch, exists.Contains);
|
||||
Assert.True(plan.HasErrors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Invalid_regex_is_an_error()
|
||||
{
|
||||
var plan = new RenamePlanner().Build(
|
||||
[File(@"C:\a\a.txt")],
|
||||
new RenameRuleSet { UseRegex = true, Search = "(" });
|
||||
Assert.False(plan.CanEnqueue);
|
||||
Assert.Contains(plan.Issues, i => i.Message.Contains("regular expression", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
}
|
||||
166
tests/Explorer.Application.Tests/ReorganizePlannerTests.cs
Normal file
166
tests/Explorer.Application.Tests/ReorganizePlannerTests.cs
Normal file
@@ -0,0 +1,166 @@
|
||||
using Explorer.Application;
|
||||
using Explorer.Domain;
|
||||
using Explorer.Domain.Abstractions;
|
||||
|
||||
namespace Explorer.Application.Tests;
|
||||
|
||||
public class ReorganizePlannerTests
|
||||
{
|
||||
private static readonly DateTimeOffset T0 = DateTimeOffset.Parse("2024-06-01T12:00:00Z");
|
||||
|
||||
[Fact]
|
||||
public void Proposes_moves_and_leaves_unknown_and_build_output()
|
||||
{
|
||||
var fs = Tree()
|
||||
.Dir(@"C:\Downloads")
|
||||
.File(@"C:\Downloads\setup.exe", 10, T0)
|
||||
.File(@"C:\Downloads\photo.jpg", 20, T0)
|
||||
.File(@"C:\Downloads\clip.mkv", 30, T0)
|
||||
.File(@"C:\Downloads\notes.pdf", 8, T0)
|
||||
.File(@"C:\Downloads\pack.zip", 4, T0)
|
||||
.File(@"C:\Downloads\weird.bin", 1, T0)
|
||||
.Dir(@"C:\Downloads\node_modules")
|
||||
.File(@"C:\Downloads\node_modules\pkg.js", 1, T0)
|
||||
.Dir(@"C:\Software")
|
||||
.Dir(@"C:\Pictures")
|
||||
.Dir(@"C:\Videos")
|
||||
.Dir(@"C:\Docs")
|
||||
.Dir(@"C:\Archive");
|
||||
|
||||
var plan = Build(fs);
|
||||
Assert.True(plan.CanEnqueue);
|
||||
Assert.Contains(plan.Operations, o => o.Op == TransferOp.Move && o.SourcePath.EndsWith("setup.exe") && o.DestinationPath == @"C:\Software\setup.exe");
|
||||
Assert.Contains(plan.Operations, o => o.DestinationPath == @"C:\Pictures\photo.jpg");
|
||||
Assert.Contains(plan.Operations, o => o.DestinationPath == @"C:\Videos\clip.mkv");
|
||||
Assert.Contains(plan.Operations, o => o.DestinationPath == @"C:\Docs\notes.pdf");
|
||||
Assert.Contains(plan.Operations, o => o.DestinationPath == @"C:\Archive\pack.zip");
|
||||
Assert.DoesNotContain(plan.Operations, o => o.SourcePath.Contains("weird.bin", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.DoesNotContain(plan.Operations, o => o.SourcePath.Contains("node_modules", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.Contains(plan.OrganizePreview, r => r.Name == "weird.bin" && r.Action == "Skip");
|
||||
Assert.Contains(plan.OrganizePreview, r => r.Name == "node_modules" && r.Action == "Skip");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Git_repo_moves_as_a_unit_to_development()
|
||||
{
|
||||
var fs = Tree()
|
||||
.Dir(@"C:\Downloads")
|
||||
.Dir(@"C:\Downloads\Explorer")
|
||||
.File(@"C:\Downloads\Explorer\README.md", 1, T0)
|
||||
.Dir(@"C:\Dev");
|
||||
|
||||
var plan = Build(fs, dest => dest.Development = @"C:\Dev", isRepoRoot: path => path.EndsWith(@"\Explorer", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.Contains(plan.Operations, o => o.SourcePath == @"C:\Downloads\Explorer" && o.DestinationPath == @"C:\Dev\Explorer");
|
||||
Assert.DoesNotContain(plan.Operations, o => o.SourcePath.Contains("README", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Missing_destination_skips_that_category()
|
||||
{
|
||||
var fs = Tree()
|
||||
.Dir(@"C:\Downloads")
|
||||
.File(@"C:\Downloads\setup.exe", 10, T0)
|
||||
.File(@"C:\Downloads\photo.jpg", 20, T0)
|
||||
.Dir(@"C:\Pictures");
|
||||
|
||||
var plan = Build(fs, dest => dest.Installers = "");
|
||||
Assert.Contains(plan.Operations, o => o.DestinationPath == @"C:\Pictures\photo.jpg");
|
||||
Assert.DoesNotContain(plan.Operations, o => o.SourcePath.EndsWith("setup.exe"));
|
||||
Assert.Contains(plan.OrganizePreview, r => r.Name == "setup.exe" && r.Action == "Skip");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Old_installer_is_a_warning_still_proposed()
|
||||
{
|
||||
var old = T0.AddYears(-2);
|
||||
var fs = Tree()
|
||||
.Dir(@"C:\Downloads")
|
||||
.File(@"C:\Downloads\old.msi", 10, old)
|
||||
.Dir(@"C:\Software");
|
||||
|
||||
var plan = Build(fs, now: T0);
|
||||
Assert.True(plan.CanEnqueue);
|
||||
Assert.Contains(plan.Operations, o => o.SourcePath.EndsWith("old.msi"));
|
||||
Assert.Contains(plan.Issues, i => i.Severity == PlanIssueSeverity.Warning && i.Message.Contains("Old", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.Contains(plan.OrganizePreview, r => r.Name == "old.msi" && r.Detail == "Older than 1 year.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Offline_destination_is_an_error_and_does_not_enqueue()
|
||||
{
|
||||
var fs = Tree()
|
||||
.Dir(@"C:\Downloads")
|
||||
.File(@"C:\Downloads\photo.jpg", 20, T0)
|
||||
.Dir(@"Z:\Pictures");
|
||||
|
||||
var plan = new ReorganizePlanner().Build(
|
||||
@"C:\Downloads",
|
||||
Map(d => d.Pictures = @"Z:\Pictures"),
|
||||
fs,
|
||||
path => path.StartsWith(@"C:\", StringComparison.OrdinalIgnoreCase),
|
||||
_ => false);
|
||||
Assert.False(plan.CanEnqueue);
|
||||
Assert.Empty(plan.Operations);
|
||||
Assert.Contains(plan.Issues, i => i.Message == "Destination is not available.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Destination_inside_the_source_is_allowed()
|
||||
{
|
||||
var fs = Tree()
|
||||
.Dir(@"C:\Downloads")
|
||||
.File(@"C:\Downloads\setup.exe", 10, T0)
|
||||
.Dir(@"C:\Downloads\Software");
|
||||
|
||||
var plan = Build(fs, dest => dest.Installers = @"C:\Downloads\Software");
|
||||
Assert.True(plan.CanEnqueue);
|
||||
Assert.Contains(plan.Operations, o => o.DestinationPath == @"C:\Downloads\Software\setup.exe");
|
||||
Assert.DoesNotContain(plan.Operations, o => o.SourcePath == @"C:\Downloads\Software");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Planner_does_not_move_files_itself()
|
||||
{
|
||||
var fs = Tree()
|
||||
.Dir(@"C:\Downloads")
|
||||
.File(@"C:\Downloads\photo.jpg", 20, T0)
|
||||
.Dir(@"C:\Pictures");
|
||||
|
||||
var plan = Build(fs);
|
||||
Assert.NotEmpty(plan.Operations);
|
||||
Assert.NotNull(fs.GetItem(@"C:\Downloads\photo.jpg"));
|
||||
Assert.Null(fs.GetItem(@"C:\Pictures\photo.jpg"));
|
||||
}
|
||||
|
||||
private static OperationPlan Build(
|
||||
IFileSystemEnumerator fs,
|
||||
Action<OrganizeDestinations>? configure = null,
|
||||
Func<string, bool>? isRepoRoot = null,
|
||||
DateTimeOffset? now = null)
|
||||
=> new ReorganizePlanner().Build(
|
||||
@"C:\Downloads",
|
||||
Map(configure),
|
||||
fs,
|
||||
_ => true,
|
||||
isRepoRoot ?? (_ => false),
|
||||
now: now ?? T0,
|
||||
pathExists: _ => false);
|
||||
|
||||
private static OrganizeDestinations Map(Action<OrganizeDestinations>? configure = null)
|
||||
{
|
||||
var dest = new OrganizeDestinations
|
||||
{
|
||||
Pictures = @"C:\Pictures",
|
||||
Videos = @"C:\Videos",
|
||||
Audio = @"C:\Music",
|
||||
Documents = @"C:\Docs",
|
||||
Installers = @"C:\Software",
|
||||
Archives = @"C:\Archive",
|
||||
Development = @"C:\Dev"
|
||||
};
|
||||
configure?.Invoke(dest);
|
||||
return dest;
|
||||
}
|
||||
|
||||
private static TreeEnumerator Tree() => new();
|
||||
}
|
||||
30
tests/Explorer.Application.Tests/SevenZipLocatorTests.cs
Normal file
30
tests/Explorer.Application.Tests/SevenZipLocatorTests.cs
Normal file
@@ -0,0 +1,30 @@
|
||||
using Explorer.Application;
|
||||
|
||||
namespace Explorer.Application.Tests;
|
||||
|
||||
public class SevenZipLocatorTests
|
||||
{
|
||||
[Fact]
|
||||
public void Prefers_the_configured_path_when_it_exists()
|
||||
{
|
||||
var path = @"C:\Tools\7z.exe";
|
||||
Assert.Equal(path, SevenZipLocator.Find(path, fileExists: p => p == path, pathVariable: ""));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Finds_7z_on_PATH_when_not_configured()
|
||||
{
|
||||
var found = SevenZipLocator.Find(
|
||||
null,
|
||||
fileExists: p => p.Equals(@"D:\bin\7z.exe", StringComparison.OrdinalIgnoreCase),
|
||||
pathVariable: @"C:\Windows;D:\bin");
|
||||
Assert.Equal(@"D:\bin\7z.exe", found);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Returns_null_when_7zip_is_missing()
|
||||
{
|
||||
Assert.Null(SevenZipLocator.Find(null, fileExists: _ => false, pathVariable: @"C:\none"));
|
||||
Assert.Contains("7-Zip", SevenZipLocator.MissingHint, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
@@ -113,7 +113,8 @@ public class SourceManagerTests
|
||||
var env = new FakeEnv(Path.GetDirectoryName(db)!);
|
||||
var mgr = new SourceManager(store, volumes, env, new SystemClock(), NullLogger<SourceManager>.Instance);
|
||||
await mgr.InitializeAsync();
|
||||
var source = (await store.Sources.GetAllAsync()).Single();
|
||||
var source = await mgr.EnsureForPathAsync(@"Z:\");
|
||||
Assert.NotNull(source);
|
||||
await store.Entries.UpsertAsync(new IndexEntry
|
||||
{
|
||||
SourceId = source.Id,
|
||||
@@ -185,6 +186,89 @@ public class SourceManagerTests
|
||||
Assert.Empty(await store.Sources.GetAllAsync());
|
||||
Assert.Empty(File.ReadAllLines(Path.Combine(dir, "recents.txt")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Forget_then_rediscover_same_volume_guid()
|
||||
{
|
||||
var db = Path.Combine(Path.GetTempPath(), "ew-app", Guid.NewGuid().ToString("N"), "index.db");
|
||||
await using var store = new SqliteIndexStore(db, NullLogger<SqliteIndexStore>.Instance);
|
||||
await store.OpenAsync();
|
||||
var guid = @"\\?\Volume{phase2}\";
|
||||
var volumes = new FakeVolumes
|
||||
{
|
||||
Online =
|
||||
[
|
||||
new VolumeFingerprint
|
||||
{
|
||||
Kind = SourceKind.Removable,
|
||||
VolumeGuid = guid,
|
||||
RootPath = @"E:\",
|
||||
DisplayName = "Stick",
|
||||
VolumeSerial = 9,
|
||||
CapacityBytes = 64
|
||||
}
|
||||
]
|
||||
};
|
||||
var env = new FakeEnv(Path.GetDirectoryName(db)!);
|
||||
var mgr = new SourceManager(store, volumes, env, new SystemClock(), NullLogger<SourceManager>.Instance);
|
||||
await mgr.InitializeAsync();
|
||||
Assert.Equal(guid, (await store.Sources.GetAllAsync()).Single().VolumeGuid);
|
||||
|
||||
volumes.Online = [];
|
||||
await mgr.RefreshOnlineStateAsync();
|
||||
Assert.True(await mgr.ForgetDisconnectedAsync(@"E:\"));
|
||||
Assert.Empty(await store.Sources.GetAllAsync());
|
||||
|
||||
volumes.Online =
|
||||
[
|
||||
new VolumeFingerprint
|
||||
{
|
||||
Kind = SourceKind.Removable,
|
||||
VolumeGuid = guid,
|
||||
RootPath = @"F:\",
|
||||
DisplayName = "Stick",
|
||||
VolumeSerial = 9,
|
||||
CapacityBytes = 64
|
||||
}
|
||||
];
|
||||
await mgr.RefreshOnlineStateAsync();
|
||||
var restored = Assert.Single(await store.Sources.GetAllAsync());
|
||||
Assert.Equal(guid, restored.VolumeGuid);
|
||||
Assert.Equal(@"F:\", restored.LastRootPath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Mapped_network_drive_is_not_auto_added_until_imported()
|
||||
{
|
||||
var db = Path.Combine(Path.GetTempPath(), "ew-app", Guid.NewGuid().ToString("N"), "index.db");
|
||||
await using var store = new SqliteIndexStore(db, NullLogger<SqliteIndexStore>.Instance);
|
||||
await store.OpenAsync();
|
||||
var volumes = new FakeVolumes
|
||||
{
|
||||
Online =
|
||||
[
|
||||
new VolumeFingerprint
|
||||
{
|
||||
Kind = SourceKind.Smb,
|
||||
RootPath = @"Z:\",
|
||||
DisplayName = "Z: (Network)",
|
||||
Filesystem = "SMB"
|
||||
}
|
||||
]
|
||||
};
|
||||
var env = new FakeEnv(Path.GetDirectoryName(db)!);
|
||||
var mgr = new SourceManager(store, volumes, env, new SystemClock(), NullLogger<SourceManager>.Instance);
|
||||
await mgr.InitializeAsync();
|
||||
Assert.Empty(await store.Sources.GetAllAsync());
|
||||
var untracked = Assert.Single(await mgr.ListUntrackedOnlineVolumesAsync());
|
||||
Assert.Equal(@"Z:\", untracked.RootPath);
|
||||
|
||||
var imported = await mgr.EnsureForPathAsync(@"Z:\");
|
||||
Assert.NotNull(imported);
|
||||
Assert.False(imported!.IsIndexed);
|
||||
Assert.Empty(await mgr.ListUntrackedOnlineVolumesAsync());
|
||||
Assert.Equal(imported.Id, (await store.Sources.GetAllAsync()).Single().Id);
|
||||
}
|
||||
}
|
||||
|
||||
file sealed class FakeVolumes : IVolumeService
|
||||
|
||||
170
tests/Explorer.Application.Tests/ThumbnailSchedulerTests.cs
Normal file
170
tests/Explorer.Application.Tests/ThumbnailSchedulerTests.cs
Normal file
@@ -0,0 +1,170 @@
|
||||
using Explorer.Application;
|
||||
|
||||
namespace Explorer.Application.Tests;
|
||||
|
||||
public class ThumbnailSchedulerTests
|
||||
{
|
||||
[Fact]
|
||||
public void Only_window_items_are_queued_not_the_whole_directory()
|
||||
{
|
||||
var scheduler = new ThumbnailScheduler();
|
||||
scheduler.Reset(1);
|
||||
scheduler.SetEnabled(true);
|
||||
var window = Enumerable.Range(0, 40)
|
||||
.Select(i => Job($@"C:\v{i}.jpg", rank: 0))
|
||||
.Concat(Enumerable.Range(0, 24).Select(i => Job($@"C:\p{i}.jpg", rank: 1)))
|
||||
.ToList();
|
||||
scheduler.SetWindow(1, window);
|
||||
|
||||
var taken = Drain(scheduler);
|
||||
Assert.Equal(64, taken.Count);
|
||||
Assert.DoesNotContain(taken, j => j.Key.Path.Contains(@"\rest", StringComparison.Ordinal));
|
||||
Assert.True(taken.Take(40).All(j => j.Rank == 0));
|
||||
Assert.Equal(0, scheduler.InFlight);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Visible_jobs_are_taken_before_prefetch()
|
||||
{
|
||||
var scheduler = new ThumbnailScheduler();
|
||||
scheduler.Reset(1);
|
||||
scheduler.SetEnabled(true);
|
||||
scheduler.SetWindow(1,
|
||||
[
|
||||
Job(@"C:\far.jpg", rank: 1),
|
||||
Job(@"C:\near.jpg", rank: 0)
|
||||
]);
|
||||
|
||||
Assert.True(scheduler.TryTake(out var first));
|
||||
Assert.Equal(@"C:\near.jpg", first.Key.Path);
|
||||
Assert.True(scheduler.TryTake(out var second));
|
||||
Assert.Equal(@"C:\far.jpg", second.Key.Path);
|
||||
scheduler.Complete(first, generated: true, failed: false);
|
||||
scheduler.Complete(second, generated: true, failed: false);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Scrolling_away_drops_obsolete_pending_work()
|
||||
{
|
||||
var scheduler = new ThumbnailScheduler();
|
||||
scheduler.Reset(1);
|
||||
scheduler.SetEnabled(true);
|
||||
scheduler.SetWindow(1, [Job(@"C:\old.jpg")]);
|
||||
scheduler.SetWindow(1, [Job(@"C:\new.jpg")]);
|
||||
|
||||
Assert.True(scheduler.TryTake(out var job));
|
||||
Assert.Equal(@"C:\new.jpg", job.Key.Path);
|
||||
Assert.False(scheduler.TryTake(out _));
|
||||
Assert.True(scheduler.Snapshot().Cancelled > 0);
|
||||
scheduler.Complete(job, generated: true, failed: false);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Navigation_generation_invalidates_previous_jobs()
|
||||
{
|
||||
var scheduler = new ThumbnailScheduler();
|
||||
scheduler.Reset(1);
|
||||
scheduler.SetEnabled(true);
|
||||
scheduler.SetWindow(1, [Job(@"C:\a.jpg")]);
|
||||
Assert.True(scheduler.TryTake(out var stale));
|
||||
scheduler.Reset(2);
|
||||
Assert.False(scheduler.IsCurrent(stale));
|
||||
Assert.False(scheduler.TryTake(out _));
|
||||
scheduler.SetWindow(2, [Job(@"C:\b.jpg", generation: 2)]);
|
||||
Assert.True(scheduler.TryTake(out var next));
|
||||
Assert.Equal(@"C:\b.jpg", next.Key.Path);
|
||||
scheduler.Complete(next, generated: true, failed: false);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Cache_hit_skips_regeneration()
|
||||
{
|
||||
var scheduler = new ThumbnailScheduler();
|
||||
scheduler.Reset(1);
|
||||
scheduler.SetEnabled(true);
|
||||
var key = ThumbnailKey.From(@"C:\hit.jpg", DateTimeOffset.UnixEpoch, 128);
|
||||
scheduler.MarkCached(key);
|
||||
scheduler.SetWindow(1, [new ThumbnailJob(key, 0, 1)]);
|
||||
Assert.False(scheduler.TryTake(out _));
|
||||
scheduler.RecordCacheHit();
|
||||
Assert.Equal(1, scheduler.Snapshot().CacheHits);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Window_is_capped_so_the_queue_cannot_hold_the_whole_directory()
|
||||
{
|
||||
var scheduler = new ThumbnailScheduler();
|
||||
scheduler.Reset(1);
|
||||
scheduler.SetEnabled(true);
|
||||
var huge = Enumerable.Range(0, 500).Select(i => Job($@"C:\{i}.jpg")).ToList();
|
||||
scheduler.SetWindow(1, huge);
|
||||
Assert.True(scheduler.Snapshot().Window <= ThumbnailScheduler.MaxPending);
|
||||
var taken = Drain(scheduler);
|
||||
Assert.Equal(ThumbnailScheduler.MaxPending, taken.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void In_flight_work_is_tracked_until_complete()
|
||||
{
|
||||
var scheduler = new ThumbnailScheduler();
|
||||
scheduler.Reset(1);
|
||||
scheduler.SetEnabled(true);
|
||||
scheduler.SetWindow(1, Enumerable.Range(0, 8).Select(i => Job($@"C:\{i}.jpg")).ToList());
|
||||
Assert.True(scheduler.TryTake(out var a));
|
||||
Assert.True(scheduler.TryTake(out var b));
|
||||
Assert.Equal(2, scheduler.InFlight);
|
||||
scheduler.Complete(a, generated: true, failed: false);
|
||||
Assert.Equal(1, scheduler.InFlight);
|
||||
scheduler.Complete(b, generated: true, failed: false);
|
||||
Assert.Equal(0, scheduler.InFlight);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Preview_disabled_cancels_window()
|
||||
{
|
||||
var scheduler = new ThumbnailScheduler();
|
||||
scheduler.Reset(1);
|
||||
scheduler.SetEnabled(true);
|
||||
scheduler.SetWindow(1, [Job(@"C:\x.jpg")]);
|
||||
scheduler.SetEnabled(false);
|
||||
Assert.False(scheduler.TryTake(out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Cache_key_changes_when_file_is_modified()
|
||||
{
|
||||
var first = ThumbnailKey.From(@"C:\a.jpg", DateTimeOffset.UnixEpoch, 128);
|
||||
var second = ThumbnailKey.From(@"C:\a.jpg", DateTimeOffset.UnixEpoch.AddHours(1), 128);
|
||||
Assert.NotEqual(first, second);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Lru_cache_evicts_oldest_when_full()
|
||||
{
|
||||
var cache = new LruCache<string, int>(2);
|
||||
cache.Set("a", 1);
|
||||
cache.Set("b", 2);
|
||||
cache.Set("c", 3);
|
||||
Assert.False(cache.TryGet("a", out _));
|
||||
Assert.True(cache.TryGet("b", out var b));
|
||||
Assert.Equal(2, b);
|
||||
cache.Set("d", 4);
|
||||
Assert.False(cache.TryGet("c", out _));
|
||||
Assert.True(cache.TryGet("b", out _));
|
||||
}
|
||||
|
||||
private static ThumbnailJob Job(string path, int rank = 0, int generation = 1)
|
||||
=> new(ThumbnailKey.From(path, DateTimeOffset.UnixEpoch, 128), rank, generation);
|
||||
|
||||
private static List<ThumbnailJob> Drain(ThumbnailScheduler scheduler)
|
||||
{
|
||||
var taken = new List<ThumbnailJob>();
|
||||
while (scheduler.TryTake(out var job))
|
||||
{
|
||||
taken.Add(job);
|
||||
scheduler.Complete(job, generated: true, failed: false);
|
||||
}
|
||||
|
||||
return taken;
|
||||
}
|
||||
}
|
||||
@@ -15,13 +15,32 @@ public class UiPreferencesStoreTests
|
||||
"group-network=true",
|
||||
"group-cloud=false",
|
||||
"index-archives=true",
|
||||
"auto-clear-queue=true"
|
||||
"auto-clear-queue=true",
|
||||
"seven-zip=C:\\Program Files\\7-Zip\\7z.exe"
|
||||
]);
|
||||
Assert.Equal("Light", prefs.Theme);
|
||||
Assert.True(prefs.GroupNetworkPlaces);
|
||||
Assert.False(prefs.GroupCloudPlaces);
|
||||
Assert.True(prefs.IndexArchiveContents);
|
||||
Assert.True(prefs.AutoClearQueueWhenDone);
|
||||
Assert.Equal(@"C:\Program Files\7-Zip\7z.exe", prefs.SevenZipPath);
|
||||
Assert.Null(prefs.GitPath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_reads_organize_destinations()
|
||||
{
|
||||
var prefs = UiPreferencesStore.Parse(["organize-installers=D:\\Software", "organize-pictures=D:\\Pictures"]);
|
||||
Assert.Equal(@"D:\Software", prefs.OrganizeInstallers);
|
||||
Assert.Equal(@"D:\Pictures", prefs.OrganizePictures);
|
||||
Assert.Null(prefs.OrganizeArchives);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_reads_git_path()
|
||||
{
|
||||
var prefs = UiPreferencesStore.Parse(["git=C:\\Program Files\\Git\\cmd\\git.exe"]);
|
||||
Assert.Equal(@"C:\Program Files\Git\cmd\git.exe", prefs.GitPath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -35,6 +54,8 @@ public class UiPreferencesStoreTests
|
||||
Assert.True(prefs.ShowHiddenFiles);
|
||||
Assert.False(prefs.ShowProtectedSystemLocations);
|
||||
Assert.False(prefs.AutoClearQueueWhenDone);
|
||||
Assert.Null(prefs.SevenZipPath);
|
||||
Assert.Null(prefs.GitPath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -105,6 +126,31 @@ public class LocationVisibilityTests
|
||||
Assert.Equal(SizeKnowledge.Unknown, LocationVisibility.ResolveSizeKnowledge(info, true, 0, true));
|
||||
Assert.Equal(SizeKnowledge.Partial, LocationVisibility.ResolveSizeKnowledge(info, true, 1000, true));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Recycle_bin_folder_is_never_shown()
|
||||
{
|
||||
var recycle = LocationClassifier.Classify(
|
||||
@"C:\$RECYCLE.BIN", "$RECYCLE.BIN",
|
||||
AttributeFlags.Directory | AttributeFlags.Hidden | AttributeFlags.System, true);
|
||||
Assert.False(LocationVisibility.ShouldShow(recycle, UiPreferences.Default));
|
||||
Assert.False(LocationVisibility.ShouldShow(recycle, UiPreferences.Default with
|
||||
{
|
||||
ShowHiddenFiles = true,
|
||||
ShowProtectedSystemLocations = true
|
||||
}));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Recycle_bin_summary_clamps_negative_query_values()
|
||||
{
|
||||
var summary = RecycleBinSummary.FromQuery(-12, -3);
|
||||
Assert.Equal(0, summary.ItemCount);
|
||||
Assert.Equal(0, summary.UsedBytes);
|
||||
var ok = RecycleBinSummary.FromQuery(4096, 3);
|
||||
Assert.Equal(3, ok.ItemCount);
|
||||
Assert.Equal(4096, ok.UsedBytes);
|
||||
}
|
||||
}
|
||||
|
||||
file sealed class PrefsEnv : IAppEnvironment
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Explorer.Domain;
|
||||
using Explorer.Domain.Abstractions;
|
||||
|
||||
namespace Explorer.Domain.Tests;
|
||||
|
||||
@@ -67,6 +68,21 @@ public class PathRulesTests
|
||||
}
|
||||
}
|
||||
|
||||
public class WindowsFileNamesTests
|
||||
{
|
||||
[Fact]
|
||||
public void Rejects_reserved_and_illegal_names()
|
||||
{
|
||||
Assert.False(WindowsFileNames.IsValid("CON", out _));
|
||||
Assert.False(WindowsFileNames.IsValid("a<.txt", out _));
|
||||
Assert.False(WindowsFileNames.IsValid("ends.", out _));
|
||||
Assert.True(WindowsFileNames.IsValid("Vacation.jpg", out _));
|
||||
Assert.Equal(("Vacation", "jpg"), WindowsFileNames.Split("Vacation.jpg"));
|
||||
Assert.Equal("Hello World", WindowsFileNames.ApplyCase("hello world", RenameCaseMode.Title));
|
||||
Assert.Equal("007", WindowsFileNames.FormatCounter(7, 3));
|
||||
}
|
||||
}
|
||||
|
||||
public class ArchiveFormatsTests
|
||||
{
|
||||
[Fact]
|
||||
@@ -79,6 +95,8 @@ public class ArchiveFormatsTests
|
||||
Assert.True(ArchiveFormats.IsZipFamily("photos.cbz"));
|
||||
Assert.False(ArchiveFormats.IsArchive("notes.txt"));
|
||||
Assert.False(ArchiveFormats.IsArchive("report.docx"));
|
||||
Assert.Equal("2019-backup", ArchiveFormats.Stem("2019-backup.7z"));
|
||||
Assert.Equal("bundle", ArchiveFormats.Stem("bundle.tar.gz"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -267,3 +285,156 @@ public class DragDropPolicyTests
|
||||
Assert.False(PathRules.IsSameVolume(@"\\media\movies\a", @"\\media\tv\b"));
|
||||
}
|
||||
}
|
||||
|
||||
public class DuplicateClassifierTests
|
||||
{
|
||||
[Fact]
|
||||
public void Same_source_and_file_id_is_hardlink_not_duplicate()
|
||||
{
|
||||
var entries = new[]
|
||||
{
|
||||
File(1, 10, 100),
|
||||
File(2, 10, 100)
|
||||
};
|
||||
Assert.True(DuplicateClassifier.IsHardlinkOnly(entries));
|
||||
Assert.Equal(DuplicateClass.Hardlink, DuplicateClassifier.Classify(entries, []));
|
||||
Assert.True(DuplicateClassifier.IsHiddenByDefault(DuplicateClass.Hardlink));
|
||||
Assert.Equal(0, DuplicateClassifier.ClassifyGroup(new DuplicateGroup { SizeBytes = 50, Entries = entries }, []).WastedBytes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Same_file_id_on_different_volumes_is_not_a_hardlink()
|
||||
{
|
||||
var entries = new[]
|
||||
{
|
||||
File(1, 10, 100, sourceId: 1),
|
||||
File(2, 10, 100, sourceId: 2)
|
||||
};
|
||||
Assert.False(DuplicateClassifier.IsHardlinkOnly(entries));
|
||||
Assert.Equal(DuplicateClass.Unknown, DuplicateClassifier.Classify(entries, []));
|
||||
Assert.Equal(2, DuplicateClassifier.UniqueFileCount(entries));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Marked_intentional_relation_is_hidden_by_default()
|
||||
{
|
||||
var entries = new[] { File(1, 10, null), File(2, 11, null) };
|
||||
var relations = new[]
|
||||
{
|
||||
new FileRelation
|
||||
{
|
||||
LeftEntryId = 1,
|
||||
RightEntryId = 2,
|
||||
Kind = FileRelationKind.IntentionalDuplicate,
|
||||
Origin = FileRelationOrigin.User,
|
||||
CreatedUtc = DateTimeOffset.UtcNow
|
||||
}
|
||||
};
|
||||
var classified = DuplicateClassifier.Classify(entries, relations);
|
||||
Assert.Equal(DuplicateClass.Intentional, classified);
|
||||
Assert.True(DuplicateClassifier.IsHiddenByDefault(classified));
|
||||
Assert.False(DuplicateClassifier.IsHiddenByDefault(DuplicateClass.Unknown));
|
||||
Assert.False(DuplicateClassifier.IsHiddenByDefault(DuplicateClass.Accidental));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sync_and_backup_relations_classify_ahead_of_unknown()
|
||||
{
|
||||
var entries = new[] { File(1, 10, 1), File(2, 11, 2) };
|
||||
Assert.Equal(DuplicateClass.Synchronized, DuplicateClassifier.Classify(entries,
|
||||
[
|
||||
Rel(1, 2, FileRelationKind.SyncCopy)
|
||||
]));
|
||||
Assert.Equal(DuplicateClass.Backup, DuplicateClassifier.Classify(entries,
|
||||
[
|
||||
Rel(1, 2, FileRelationKind.BackupCopy)
|
||||
]));
|
||||
}
|
||||
|
||||
private static IndexEntry File(long id, long sourceFileId, long? fileId, long sourceId = 1)
|
||||
=> new()
|
||||
{
|
||||
Id = id,
|
||||
SourceId = sourceId,
|
||||
Name = id + ".bin",
|
||||
NameNorm = id + ".bin",
|
||||
PathRel = id + ".bin",
|
||||
SizeBytes = 50,
|
||||
FileId = fileId,
|
||||
LastSeenUtc = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
private static FileRelation Rel(long left, long right, FileRelationKind kind)
|
||||
=> new()
|
||||
{
|
||||
LeftEntryId = left,
|
||||
RightEntryId = right,
|
||||
Kind = kind,
|
||||
Origin = FileRelationOrigin.User,
|
||||
CreatedUtc = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
public class OperationProfileTests
|
||||
{
|
||||
[Fact]
|
||||
public void Auto_run_is_copy_only()
|
||||
{
|
||||
var copy = new OperationProfile { Name = "Copy", DoCopy = true, AutoRun = true };
|
||||
Assert.True(copy.CanAutoRun);
|
||||
|
||||
var compress = new OperationProfile { Name = "Archive", DoCopy = true, DoCompress = true, AutoRun = true };
|
||||
Assert.False(compress.CanAutoRun);
|
||||
|
||||
var rename = new OperationProfile { Name = "Rename", DoCopy = true, DoRename = true, RenamePrefix = "x_", AutoRun = true };
|
||||
Assert.False(rename.CanAutoRun);
|
||||
}
|
||||
}
|
||||
|
||||
public class FileClassifierTests
|
||||
{
|
||||
[Fact]
|
||||
public void Extension_and_archive_signals()
|
||||
{
|
||||
Assert.Equal(FileCategory.Photos, Classify("sunset.jpg"));
|
||||
Assert.Equal(FileCategory.Video, Classify("clip.mkv"));
|
||||
Assert.Equal(FileCategory.Audio, Classify("track.mp3"));
|
||||
Assert.Equal(FileCategory.Documents, Classify("notes.pdf"));
|
||||
Assert.Equal(FileCategory.Installer, Classify("Git-64-bit.exe"));
|
||||
Assert.Equal(FileCategory.Archive, Classify("pack.7z"));
|
||||
Assert.Equal(FileCategory.Backup, Classify("db.bak"));
|
||||
Assert.Equal(FileCategory.Unknown, Classify("mystery.bin"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Folder_structure_and_git()
|
||||
{
|
||||
Assert.Equal(FileCategory.CodeRepository, Classify("Explorer", isDirectory: true, isRepoRoot: true));
|
||||
Assert.Equal(FileCategory.BuildOutput, Classify("node_modules", isDirectory: true));
|
||||
Assert.Equal(FileCategory.BuildOutput, Classify("bin", isDirectory: true));
|
||||
Assert.Equal(FileCategory.Photos, Classify("DCIM", isDirectory: true));
|
||||
Assert.Equal(FileCategory.SystemData, FileClassifier.Classify(
|
||||
"$RECYCLE.BIN", @"C:\$RECYCLE.BIN", true,
|
||||
AttributeFlags.Directory | AttributeFlags.Hidden | AttributeFlags.System).Category);
|
||||
Assert.True(FileClassifier.ShouldLeave(FileCategory.Unknown));
|
||||
Assert.True(FileClassifier.ShouldLeave(FileCategory.BuildOutput));
|
||||
Assert.False(FileClassifier.ShouldLeave(FileCategory.Photos));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Majority_of_children_classifies_a_folder()
|
||||
{
|
||||
var mixed = FileClassifier.Classify(
|
||||
"Vacation", @"C:\Downloads\Vacation", true, AttributeFlags.Directory,
|
||||
childCategories: [FileCategory.Photos, FileCategory.Photos, FileCategory.Photos, FileCategory.Documents]);
|
||||
Assert.Equal(FileCategory.Photos, mixed.Category);
|
||||
|
||||
var split = FileClassifier.Classify(
|
||||
"Misc", @"C:\Downloads\Misc", true, AttributeFlags.Directory,
|
||||
childCategories: [FileCategory.Photos, FileCategory.Video]);
|
||||
Assert.Equal(FileCategory.Unknown, split.Category);
|
||||
}
|
||||
|
||||
private static FileCategory Classify(string name, bool isDirectory = false, bool isRepoRoot = false)
|
||||
=> FileClassifier.Classify(name, @"C:\Downloads\" + name, isDirectory, isDirectory ? AttributeFlags.Directory : 0, isRepoRoot).Category;
|
||||
}
|
||||
|
||||
@@ -44,4 +44,5 @@ file sealed class StubShell : IShellFileOperations
|
||||
{ error = "n/a"; return false; }
|
||||
public bool CreateShortcut(string targetPath, string shortcutPath, out string? error)
|
||||
{ error = null; return true; }
|
||||
public bool EmptyRecycleBin(out string? error) { error = null; return true; }
|
||||
}
|
||||
|
||||
164
tests/Explorer.FileOperations.Tests/FolderSyncServiceTests.cs
Normal file
164
tests/Explorer.FileOperations.Tests/FolderSyncServiceTests.cs
Normal file
@@ -0,0 +1,164 @@
|
||||
using Explorer.Application;
|
||||
using Explorer.Domain;
|
||||
using Explorer.Domain.Abstractions;
|
||||
using Explorer.FileOperations;
|
||||
using Explorer.Storage.Sqlite;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace Explorer.FileOperations.Tests;
|
||||
|
||||
public class FolderSyncServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Enqueue_copy_waits_when_the_destination_is_offline()
|
||||
{
|
||||
await using var ctx = await SyncHarness.CreateAsync();
|
||||
var plan = await ctx.Sync.PreviewAsync(ctx.Profile);
|
||||
Assert.True(plan.CanEnqueue);
|
||||
ctx.Volumes.Reachable = false;
|
||||
await ctx.Queue.StartAsync(CancellationToken.None);
|
||||
await ctx.Sync.EnqueueAsync(ctx.Profile, plan);
|
||||
await WaitUntil(() => ctx.Queue.Snapshot().Any(j => j.Status == TransferStatus.Waiting));
|
||||
Assert.Equal(0, ctx.Shell.CopyCount);
|
||||
ctx.Volumes.Reachable = true;
|
||||
ctx.Queue.NotifyAvailability();
|
||||
await WaitUntil(() => ctx.Queue.Snapshot().All(j => j.Status == TransferStatus.Done));
|
||||
Assert.True(File.Exists(Path.Combine(ctx.Dest, "a.txt")));
|
||||
await ctx.Queue.StopAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Auto_run_fires_only_after_the_destination_returns()
|
||||
{
|
||||
await using var ctx = await SyncHarness.CreateAsync();
|
||||
ctx.Profile.AutoRun = true;
|
||||
await ctx.Sync.SaveAsync(ctx.Profile);
|
||||
await ctx.Sync.TryAutoRunAsync();
|
||||
Assert.Empty(ctx.Queue.Snapshot());
|
||||
|
||||
ctx.Volumes.Reachable = false;
|
||||
await ctx.Sync.TryAutoRunAsync();
|
||||
Assert.Empty(ctx.Queue.Snapshot());
|
||||
|
||||
ctx.Volumes.Reachable = true;
|
||||
await ctx.Queue.StartAsync(CancellationToken.None);
|
||||
await ctx.Sync.TryAutoRunAsync();
|
||||
await WaitUntil(() => ctx.Queue.Snapshot().Any(j => j.Status == TransferStatus.Done));
|
||||
Assert.True(File.Exists(Path.Combine(ctx.Dest, "a.txt")));
|
||||
|
||||
File.WriteAllText(Path.Combine(ctx.Root, "b.txt"), "b");
|
||||
await ctx.Sync.TryAutoRunAsync();
|
||||
Assert.DoesNotContain(ctx.Queue.Snapshot(), j => j.SourcePath.EndsWith("b.txt", StringComparison.OrdinalIgnoreCase));
|
||||
await ctx.Queue.StopAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Mirror_auto_run_is_ignored()
|
||||
{
|
||||
await using var ctx = await SyncHarness.CreateAsync();
|
||||
ctx.Profile.Mode = SyncMode.Mirror;
|
||||
ctx.Profile.AutoRun = true;
|
||||
await ctx.Sync.SaveAsync(ctx.Profile);
|
||||
ctx.Volumes.Reachable = false;
|
||||
await ctx.Sync.TryAutoRunAsync();
|
||||
ctx.Volumes.Reachable = true;
|
||||
await ctx.Sync.TryAutoRunAsync();
|
||||
Assert.Empty(ctx.Queue.Snapshot());
|
||||
}
|
||||
|
||||
private static async Task WaitUntil(Func<bool> condition)
|
||||
{
|
||||
var limit = DateTime.UtcNow + TimeSpan.FromSeconds(4);
|
||||
while (!condition())
|
||||
{
|
||||
if (DateTime.UtcNow > limit)
|
||||
{
|
||||
throw new TimeoutException("Condition was not met.");
|
||||
}
|
||||
|
||||
await Task.Delay(20);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class SyncHarness : IAsyncDisposable
|
||||
{
|
||||
public required FolderSyncService Sync { get; init; }
|
||||
public required TransferQueue Queue { get; init; }
|
||||
public required GateShell Shell { get; init; }
|
||||
public required ControlledVolumes Volumes { get; init; }
|
||||
public required SqliteIndexStore Store { get; init; }
|
||||
public required SyncProfile Profile { get; init; }
|
||||
public required string Root { get; init; }
|
||||
public required string Dest { get; init; }
|
||||
|
||||
public static async Task<SyncHarness> CreateAsync()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "ew-sync", Guid.NewGuid().ToString("N"));
|
||||
var source = Path.Combine(root, "src");
|
||||
var dest = Path.Combine(root, "dest");
|
||||
Directory.CreateDirectory(source);
|
||||
Directory.CreateDirectory(dest);
|
||||
File.WriteAllText(Path.Combine(source, "a.txt"), "a");
|
||||
var store = new SqliteIndexStore(Path.Combine(root, "index.db"), NullLogger<SqliteIndexStore>.Instance);
|
||||
await store.OpenAsync();
|
||||
var shell = new GateShell();
|
||||
var volumes = new ControlledVolumes();
|
||||
var enumerator = new DiskEnum();
|
||||
var queue = new TransferQueue(
|
||||
new NativeFileOperationExecutor(shell, enumerator),
|
||||
store,
|
||||
volumes,
|
||||
NullLogger<TransferQueue>.Instance);
|
||||
var ops = new FileOperationService(queue, shell, enumerator);
|
||||
var env = new SyncEnv(root);
|
||||
var sources = new SourceManager(store, volumes, env, new SystemClock(), NullLogger<SourceManager>.Instance);
|
||||
var sync = new FolderSyncService(
|
||||
new FolderSyncPlanner(),
|
||||
store,
|
||||
sources,
|
||||
ops,
|
||||
volumes,
|
||||
enumerator,
|
||||
new NeverHydrate());
|
||||
return new SyncHarness
|
||||
{
|
||||
Sync = sync,
|
||||
Queue = queue,
|
||||
Shell = shell,
|
||||
Volumes = volumes,
|
||||
Store = store,
|
||||
Profile = new SyncProfile
|
||||
{
|
||||
Name = "Test",
|
||||
SourcePath = source,
|
||||
DestPath = dest,
|
||||
Mode = SyncMode.CopyUpdate
|
||||
},
|
||||
Root = source,
|
||||
Dest = dest
|
||||
};
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await Store.DisposeAsync();
|
||||
var work = Path.GetDirectoryName(Root);
|
||||
try { if (work is not null) Directory.Delete(work, true); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class SyncEnv(string dir) : IAppEnvironment
|
||||
{
|
||||
public string DataDirectory { get; } = dir;
|
||||
public string DatabasePath { get; } = Path.Combine(dir, "index.db");
|
||||
public string LogDirectory { get; } = Path.Combine(dir, "logs");
|
||||
}
|
||||
|
||||
private sealed class NeverHydrate : IHydrationGuard
|
||||
{
|
||||
public bool WouldHydrateOnRead(FileSystemItem item) => false;
|
||||
public bool WouldHydrateOnRead(int attributes, CloudAvailability? availability) => false;
|
||||
public Task<bool> WouldHydrateOnReadAsync(string path, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
using Explorer.Application;
|
||||
using Explorer.Domain;
|
||||
using Explorer.Domain.Abstractions;
|
||||
using Explorer.FileOperations;
|
||||
using Explorer.Storage.Sqlite;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace Explorer.FileOperations.Tests;
|
||||
|
||||
public class OperationProfileServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Dirty_git_does_not_enqueue()
|
||||
{
|
||||
await using var ctx = await ProfileHarness.CreateAsync();
|
||||
ctx.Git.Status = new GitStatus { RepoRoot = ctx.Root, Branch = "main", ModifiedCount = 2 };
|
||||
ctx.Profile.RequireGitClean = true;
|
||||
var plan = await ctx.Profiles.EnqueueAsync(ctx.Profile);
|
||||
Assert.False(plan.CanEnqueue);
|
||||
Assert.Contains(plan.Issues, i => i.Message == "Working tree is not clean.");
|
||||
Assert.Empty(ctx.Queue.Snapshot());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Auto_run_fires_only_after_the_destination_returns()
|
||||
{
|
||||
await using var ctx = await ProfileHarness.CreateAsync();
|
||||
ctx.Profile.AutoRun = true;
|
||||
await ctx.Profiles.SaveAsync(ctx.Profile);
|
||||
await ctx.Profiles.TryAutoRunAsync();
|
||||
Assert.Empty(ctx.Queue.Snapshot());
|
||||
|
||||
ctx.Volumes.Reachable = false;
|
||||
await ctx.Profiles.TryAutoRunAsync();
|
||||
Assert.Empty(ctx.Queue.Snapshot());
|
||||
|
||||
ctx.Volumes.Reachable = true;
|
||||
await ctx.Queue.StartAsync(CancellationToken.None);
|
||||
await ctx.Profiles.TryAutoRunAsync();
|
||||
await WaitUntil(() => ctx.Queue.Snapshot().Any(j => j.Status == TransferStatus.Done));
|
||||
Assert.True(File.Exists(Path.Combine(ctx.Dest, Path.GetFileName(ctx.Root), "a.txt")));
|
||||
|
||||
File.WriteAllText(Path.Combine(ctx.Root, "b.txt"), "b");
|
||||
await ctx.Profiles.TryAutoRunAsync();
|
||||
Assert.DoesNotContain(ctx.Queue.Snapshot(), j => j.SourcePath.EndsWith("b.txt", StringComparison.OrdinalIgnoreCase));
|
||||
await ctx.Queue.StopAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Compress_auto_run_is_ignored()
|
||||
{
|
||||
await using var ctx = await ProfileHarness.CreateAsync();
|
||||
ctx.Profile.DoCompress = true;
|
||||
ctx.Profile.AutoRun = true;
|
||||
await ctx.Profiles.SaveAsync(ctx.Profile);
|
||||
Assert.False((await ctx.Store.OperationProfiles.GetAsync(ctx.Profile.Id))!.CanAutoRun);
|
||||
ctx.Volumes.Reachable = false;
|
||||
await ctx.Profiles.TryAutoRunAsync();
|
||||
ctx.Volumes.Reachable = true;
|
||||
await ctx.Profiles.TryAutoRunAsync();
|
||||
Assert.Empty(ctx.Queue.Snapshot());
|
||||
}
|
||||
|
||||
private static async Task WaitUntil(Func<bool> condition)
|
||||
{
|
||||
var limit = DateTime.UtcNow + TimeSpan.FromSeconds(4);
|
||||
while (!condition())
|
||||
{
|
||||
if (DateTime.UtcNow > limit)
|
||||
{
|
||||
throw new TimeoutException("Condition was not met.");
|
||||
}
|
||||
|
||||
await Task.Delay(20);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ProfileHarness : IAsyncDisposable
|
||||
{
|
||||
public required OperationProfileService Profiles { get; init; }
|
||||
public required TransferQueue Queue { get; init; }
|
||||
public required ControlledVolumes Volumes { get; init; }
|
||||
public required SqliteIndexStore Store { get; init; }
|
||||
public required OperationProfile Profile { get; init; }
|
||||
public required StubGit Git { get; init; }
|
||||
public required string Root { get; init; }
|
||||
public required string Dest { get; init; }
|
||||
|
||||
public static async Task<ProfileHarness> CreateAsync()
|
||||
{
|
||||
var work = Path.Combine(Path.GetTempPath(), "ew-op", Guid.NewGuid().ToString("N"));
|
||||
var source = Path.Combine(work, "src");
|
||||
var dest = Path.Combine(work, "dest");
|
||||
Directory.CreateDirectory(source);
|
||||
Directory.CreateDirectory(dest);
|
||||
File.WriteAllText(Path.Combine(source, "a.txt"), "a");
|
||||
var store = new SqliteIndexStore(Path.Combine(work, "index.db"), NullLogger<SqliteIndexStore>.Instance);
|
||||
await store.OpenAsync();
|
||||
var shell = new GateShell();
|
||||
var volumes = new ControlledVolumes();
|
||||
var enumerator = new DiskEnum();
|
||||
var queue = new TransferQueue(
|
||||
new NativeFileOperationExecutor(shell, enumerator),
|
||||
store,
|
||||
volumes,
|
||||
NullLogger<TransferQueue>.Instance);
|
||||
var ops = new FileOperationService(queue, shell, enumerator);
|
||||
var git = new StubGit();
|
||||
var planner = new FileOperationProfilePlanner(new RenamePlanner());
|
||||
var renames = new RenameBatchService(new RenamePlanner(), store, ops);
|
||||
var profiles = new OperationProfileService(
|
||||
planner,
|
||||
store,
|
||||
ops,
|
||||
renames,
|
||||
volumes,
|
||||
enumerator,
|
||||
git,
|
||||
new NeverHydrate(),
|
||||
new FakeArchiveExecutor());
|
||||
return new ProfileHarness
|
||||
{
|
||||
Profiles = profiles,
|
||||
Queue = queue,
|
||||
Volumes = volumes,
|
||||
Store = store,
|
||||
Git = git,
|
||||
Profile = new OperationProfile
|
||||
{
|
||||
Name = "Copy",
|
||||
SourcePath = source,
|
||||
DestPath = dest,
|
||||
DoCopy = true
|
||||
},
|
||||
Root = source,
|
||||
Dest = dest
|
||||
};
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await Store.DisposeAsync();
|
||||
var work = Path.GetDirectoryName(Root);
|
||||
try { if (work is not null) Directory.Delete(work, true); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class StubGit : IGitStatusProvider
|
||||
{
|
||||
public bool IsAvailable { get; set; } = true;
|
||||
public GitStatus? Status { get; set; }
|
||||
public string? FindRepoRoot(string path) => Status?.RepoRoot;
|
||||
public bool IsRepoRoot(string path) => Status is not null;
|
||||
public Task<GitStatus?> GetStatusAsync(string path, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(Status);
|
||||
}
|
||||
|
||||
private sealed class NeverHydrate : IHydrationGuard
|
||||
{
|
||||
public bool WouldHydrateOnRead(FileSystemItem item) => false;
|
||||
public bool WouldHydrateOnRead(int attributes, CloudAvailability? availability) => false;
|
||||
public Task<bool> WouldHydrateOnReadAsync(string path, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(false);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using Explorer.Application;
|
||||
using Explorer.Domain;
|
||||
using Explorer.Domain.Abstractions;
|
||||
using Explorer.FileOperations;
|
||||
@@ -147,10 +148,273 @@ public class TransferQueueTests
|
||||
await ctx.Queue.StopAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Restores_paused_and_queued_jobs_after_restart()
|
||||
{
|
||||
await using var ctx = await Harness.CreateAsync();
|
||||
ctx.Queue.PauseAll();
|
||||
await ctx.Queue.StartAsync(CancellationToken.None);
|
||||
await ctx.Queue.EnqueueCopyAsync([ctx.File("a.txt"), ctx.File("b.txt")], ctx.Dest);
|
||||
var jobs = ctx.Queue.Snapshot();
|
||||
ctx.Queue.Pause(jobs[0].Id);
|
||||
await WaitUntil(async () =>
|
||||
{
|
||||
var stored = await ctx.Store.Transfers.GetIncompleteAsync();
|
||||
return stored.Any(j => j.Id == jobs[0].Id && j.Status == TransferStatus.Paused);
|
||||
});
|
||||
await ctx.Queue.StopAsync(CancellationToken.None);
|
||||
|
||||
var restored = ctx.CreateQueue();
|
||||
restored.PauseAll();
|
||||
await restored.StartAsync(CancellationToken.None);
|
||||
await WaitUntil(() => restored.Snapshot().Count == 2);
|
||||
Assert.Equal(TransferStatus.Paused, restored.Snapshot().First(j => j.Id == jobs[0].Id).Status);
|
||||
Assert.Equal(TransferStatus.Queued, restored.Snapshot().First(j => j.Id == jobs[1].Id).Status);
|
||||
restored.Resume(jobs[0].Id);
|
||||
await WaitUntil(() => restored.Snapshot().Count(j => j.Status == TransferStatus.Done) == 2);
|
||||
await restored.StopAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Restores_running_jobs_as_paused()
|
||||
{
|
||||
await using var ctx = await Harness.CreateAsync();
|
||||
var id = await ctx.Store.Transfers.InsertAsync(new TransferJob
|
||||
{
|
||||
Op = TransferOp.Copy,
|
||||
SourcePath = ctx.File("a.txt"),
|
||||
DestinationPath = Path.Combine(ctx.Dest, "a.txt"),
|
||||
Status = TransferStatus.Running,
|
||||
CreatedUtc = DateTimeOffset.UtcNow,
|
||||
StartedUtc = DateTimeOffset.UtcNow
|
||||
});
|
||||
var restored = ctx.CreateQueue();
|
||||
await restored.StartAsync(CancellationToken.None);
|
||||
await WaitUntil(() => restored.Snapshot().Any(j => j.Id == id && j.Status == TransferStatus.Paused));
|
||||
Assert.DoesNotContain(restored.Snapshot(), j => j.Status == TransferStatus.Running);
|
||||
await restored.StopAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Waiting_jobs_do_not_run_until_destination_is_reachable()
|
||||
{
|
||||
await using var ctx = await Harness.CreateAsync();
|
||||
ctx.Volumes.Reachable = false;
|
||||
await ctx.Queue.StartAsync(CancellationToken.None);
|
||||
await ctx.Queue.EnqueueCopyAsync([ctx.File("a.txt")], ctx.Dest);
|
||||
await WaitUntil(() => ctx.Queue.Snapshot().Any(j => j.Status == TransferStatus.Waiting));
|
||||
await Task.Delay(80);
|
||||
Assert.Equal(0, ctx.Shell.CopyCount);
|
||||
Assert.Equal(FileOperationErrors.DestinationUnavailable, ctx.Queue.Snapshot()[0].WaitReason);
|
||||
|
||||
ctx.Volumes.Reachable = true;
|
||||
ctx.Queue.NotifyAvailability();
|
||||
await WaitUntil(() => ctx.Queue.Snapshot().Single().Status == TransferStatus.Done);
|
||||
Assert.Equal(new[] { ctx.File("a.txt") }, ctx.Shell.Copied);
|
||||
await ctx.Queue.StopAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Retry_reruns_a_failed_job()
|
||||
{
|
||||
await using var ctx = await Harness.CreateAsync();
|
||||
ctx.Shell.FailRemaining = 1;
|
||||
ctx.Shell.FailError = "disk full";
|
||||
await ctx.Queue.StartAsync(CancellationToken.None);
|
||||
await ctx.Queue.EnqueueCopyAsync([ctx.File("a.txt")], ctx.Dest);
|
||||
await WaitUntil(() => ctx.Queue.Snapshot().Single().Status == TransferStatus.Failed);
|
||||
Assert.Equal("disk full", ctx.Queue.Snapshot()[0].Error);
|
||||
|
||||
ctx.Queue.Retry(ctx.Queue.Snapshot()[0].Id);
|
||||
await WaitUntil(() => ctx.Queue.Snapshot().Single().Status == TransferStatus.Done);
|
||||
Assert.Equal(1, ctx.Queue.Snapshot()[0].RetryCount);
|
||||
Assert.Contains(ctx.File("a.txt"), ctx.Shell.Copied);
|
||||
await ctx.Queue.StopAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task File_lock_fails_and_can_be_retried()
|
||||
{
|
||||
await using var ctx = await Harness.CreateAsync();
|
||||
ctx.Shell.FailRemaining = 1;
|
||||
ctx.Shell.FailError = "The process cannot access the file because it is being used by another process.";
|
||||
await ctx.Queue.StartAsync(CancellationToken.None);
|
||||
await ctx.Queue.EnqueueCopyAsync([ctx.File("a.txt")], ctx.Dest);
|
||||
await WaitUntil(() => ctx.Queue.Snapshot().Single().Status == TransferStatus.Failed);
|
||||
Assert.Equal(FileOperationErrors.FileInUse, ctx.Queue.Snapshot()[0].Error);
|
||||
|
||||
ctx.Queue.Retry(ctx.Queue.Snapshot()[0].Id);
|
||||
await WaitUntil(() => ctx.Queue.Snapshot().Single().Status == TransferStatus.Done);
|
||||
await ctx.Queue.StopAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Queued_rename_collision_stays_failed()
|
||||
{
|
||||
await using var ctx = await Harness.CreateAsync();
|
||||
await ctx.Queue.StartAsync(CancellationToken.None);
|
||||
await ctx.Queue.EnqueueRenameAsync(ctx.File("a.txt"), "b.txt");
|
||||
await WaitUntil(() => ctx.Queue.Snapshot().Single().Status == TransferStatus.Failed);
|
||||
Assert.Equal(FileOperationErrors.NameExists, ctx.Queue.Snapshot()[0].Error);
|
||||
Assert.True(System.IO.File.Exists(ctx.File("a.txt")));
|
||||
Assert.True(System.IO.File.Exists(ctx.File("b.txt")));
|
||||
await ctx.Queue.StopAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Queued_rename_moves_the_file()
|
||||
{
|
||||
await using var ctx = await Harness.CreateAsync();
|
||||
await ctx.Queue.StartAsync(CancellationToken.None);
|
||||
await ctx.Queue.EnqueueRenameAsync(ctx.File("a.txt"), "renamed.txt");
|
||||
await WaitUntil(() => ctx.Queue.Snapshot().Single().Status == TransferStatus.Done);
|
||||
Assert.False(System.IO.File.Exists(ctx.File("a.txt")));
|
||||
Assert.True(System.IO.File.Exists(ctx.File("renamed.txt")));
|
||||
await ctx.Queue.StopAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ClearFinished_keeps_history_and_does_not_restore_dismissed_jobs()
|
||||
{
|
||||
await using var ctx = await Harness.CreateAsync();
|
||||
await ctx.Queue.StartAsync(CancellationToken.None);
|
||||
await ctx.Queue.EnqueueCopyAsync([ctx.File("a.txt")], ctx.Dest);
|
||||
await WaitUntil(() => ctx.Queue.Snapshot().Single().Status == TransferStatus.Done);
|
||||
ctx.Queue.ClearFinished();
|
||||
Assert.Empty(ctx.Queue.Snapshot());
|
||||
await WaitUntil(async () =>
|
||||
{
|
||||
var history = await ctx.Store.Transfers.GetHistoryAsync(10);
|
||||
return history.Any(j => j.Status == TransferStatus.Done && j.Dismissed);
|
||||
});
|
||||
await ctx.Queue.StopAsync(CancellationToken.None);
|
||||
|
||||
var restored = ctx.CreateQueue();
|
||||
await restored.StartAsync(CancellationToken.None);
|
||||
await Task.Delay(50);
|
||||
Assert.Empty(restored.Snapshot());
|
||||
await restored.StopAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Batch_rename_enqueues_each_item()
|
||||
{
|
||||
await using var ctx = await Harness.CreateAsync();
|
||||
var ops = new FileOperationService(ctx.Queue, ctx.Shell, new DiskEnum());
|
||||
ctx.Queue.PauseAll();
|
||||
await ctx.Queue.StartAsync(CancellationToken.None);
|
||||
await ops.EnqueueRenameAsync([(ctx.File("a.txt"), "a2.txt"), (ctx.File("b.txt"), "b2.txt")]);
|
||||
Assert.Equal(2, ctx.Queue.Snapshot().Count);
|
||||
Assert.All(ctx.Queue.Snapshot(), j => Assert.Equal(TransferOp.Rename, j.Op));
|
||||
ctx.Queue.ResumeAll();
|
||||
await WaitUntil(() => ctx.Queue.Snapshot().Count(j => j.Status == TransferStatus.Done) == 2);
|
||||
await ctx.Queue.StopAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Batch_rename_then_undo_restores_names()
|
||||
{
|
||||
await using var ctx = await Harness.CreateAsync();
|
||||
var ops = new FileOperationService(ctx.Queue, ctx.Shell, new DiskEnum());
|
||||
var batches = new RenameBatchService(new RenamePlanner(), ctx.Store, ops);
|
||||
await ctx.Queue.StartAsync(CancellationToken.None);
|
||||
var subjects = new[]
|
||||
{
|
||||
new RenameSubject(ctx.File("a.txt"), "a.txt", false),
|
||||
new RenameSubject(ctx.File("b.txt"), "b.txt", false)
|
||||
};
|
||||
var plan = batches.Preview(subjects, new RenameRuleSet { Prefix = "x_" });
|
||||
Assert.True(plan.CanEnqueue);
|
||||
await batches.EnqueueAsync(plan);
|
||||
await WaitUntil(() => File.Exists(ctx.File("x_a.txt")) && File.Exists(ctx.File("x_b.txt")));
|
||||
Assert.False(File.Exists(ctx.File("a.txt")));
|
||||
var undo = await batches.UndoLastAsync();
|
||||
Assert.True(undo.CanEnqueue);
|
||||
await WaitUntil(() => File.Exists(ctx.File("a.txt")) && File.Exists(ctx.File("b.txt")));
|
||||
Assert.False(File.Exists(ctx.File("x_a.txt")));
|
||||
Assert.Null(await batches.GetUndoableAsync());
|
||||
await ctx.Queue.StopAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Extract_fails_when_7zip_is_missing()
|
||||
{
|
||||
await using var ctx = await Harness.CreateAsync();
|
||||
var ops = new FileOperationService(ctx.Queue, ctx.Shell, new DiskEnum());
|
||||
await ctx.Queue.StartAsync(CancellationToken.None);
|
||||
await ops.ExtractAsync(ctx.File("a.txt"), Path.Combine(ctx.Dest, "out"));
|
||||
await WaitUntil(() => ctx.Queue.Snapshot().Any(j =>
|
||||
j.Op == TransferOp.Extract && j.Status == TransferStatus.Failed));
|
||||
var job = ctx.Queue.Snapshot().Single(j => j.Op == TransferOp.Extract);
|
||||
Assert.Contains("7-Zip", job.Error, StringComparison.OrdinalIgnoreCase);
|
||||
await ctx.Queue.StopAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Fake_extract_writes_files_and_can_be_cancelled()
|
||||
{
|
||||
var fake = new FakeArchiveExecutor { ExtractDelay = TimeSpan.FromSeconds(20) };
|
||||
await using var ctx = await Harness.CreateAsync(fake);
|
||||
var ops = new FileOperationService(ctx.Queue, ctx.Shell, new DiskEnum());
|
||||
await ctx.Queue.StartAsync(CancellationToken.None);
|
||||
var dest = Path.Combine(ctx.Dest, "pack");
|
||||
await ops.ExtractAsync(ctx.File("a.txt"), dest);
|
||||
await fake.Started.Task.WaitAsync(TimeSpan.FromSeconds(4));
|
||||
var job = Assert.Single(ctx.Queue.Snapshot(), j => j.Op == TransferOp.Extract);
|
||||
ctx.Queue.Cancel(job.Id);
|
||||
await WaitUntil(() => ctx.Queue.Snapshot().Any(j =>
|
||||
j.Op == TransferOp.Extract && j.Status == TransferStatus.Cancelled));
|
||||
await ctx.Queue.StopAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Fake_extract_completes_and_writes_output()
|
||||
{
|
||||
var fake = new FakeArchiveExecutor();
|
||||
await using var ctx = await Harness.CreateAsync(fake);
|
||||
var ops = new FileOperationService(ctx.Queue, ctx.Shell, new DiskEnum());
|
||||
await ctx.Queue.StartAsync(CancellationToken.None);
|
||||
var dest = Path.Combine(ctx.Dest, "pack");
|
||||
await ops.ExtractAsync(ctx.File("a.txt"), dest);
|
||||
await WaitUntil(() => ctx.Queue.Snapshot().Any(j =>
|
||||
j.Op == TransferOp.Extract && j.Status == TransferStatus.Done));
|
||||
Assert.True(File.Exists(Path.Combine(dest, "out.txt")));
|
||||
await ctx.Queue.StopAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Extract_refuses_online_only_cloud_archives()
|
||||
{
|
||||
await using var ctx = await Harness.CreateAsync(new FakeArchiveExecutor(), new AlwaysHydrate());
|
||||
var ops = new FileOperationService(ctx.Queue, ctx.Shell, new DiskEnum());
|
||||
await ctx.Queue.StartAsync(CancellationToken.None);
|
||||
await ops.ExtractAsync(ctx.File("a.txt"), Path.Combine(ctx.Dest, "out"));
|
||||
await WaitUntil(() => ctx.Queue.Snapshot().Any(j =>
|
||||
j.Op == TransferOp.Extract && j.Status == TransferStatus.Failed));
|
||||
Assert.Equal(FileOperationErrors.CloudHydration, ctx.Queue.Snapshot().Single().Error);
|
||||
await ctx.Queue.StopAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Empty_recycle_bin_goes_through_the_queue()
|
||||
{
|
||||
await using var ctx = await Harness.CreateAsync();
|
||||
var ops = new FileOperationService(ctx.Queue, ctx.Shell, new DiskEnum());
|
||||
await ctx.Queue.StartAsync(CancellationToken.None);
|
||||
await ops.EmptyRecycleBinAsync();
|
||||
await WaitUntil(() => ctx.Queue.Snapshot().Any(j =>
|
||||
j.Op == TransferOp.EmptyRecycleBin && j.Status == TransferStatus.Done));
|
||||
Assert.Equal(1, ctx.Shell.EmptyCount);
|
||||
await ctx.Queue.StopAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
private static async Task WaitUntil(Func<bool> condition, TimeSpan? timeout = null)
|
||||
=> await WaitUntil(() => Task.FromResult(condition()), timeout);
|
||||
|
||||
private static async Task WaitUntil(Func<Task<bool>> condition, TimeSpan? timeout = null)
|
||||
{
|
||||
var limit = DateTime.UtcNow + (timeout ?? TimeSpan.FromSeconds(4));
|
||||
while (!condition())
|
||||
while (!await condition().ConfigureAwait(false))
|
||||
{
|
||||
if (DateTime.UtcNow > limit)
|
||||
{
|
||||
@@ -166,12 +430,15 @@ public class TransferQueueTests
|
||||
public required TransferQueue Queue { get; init; }
|
||||
public required GateShell Shell { get; init; }
|
||||
public required SqliteIndexStore Store { get; init; }
|
||||
public required ControlledVolumes Volumes { get; init; }
|
||||
public required string Dest { get; init; }
|
||||
public required string Root { get; init; }
|
||||
|
||||
public string File(string name) => Path.Combine(Root, name);
|
||||
|
||||
public static async Task<Harness> CreateAsync()
|
||||
public static async Task<Harness> CreateAsync(
|
||||
IArchiveExecutor? archives = null,
|
||||
IHydrationGuard? hydration = null)
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "ew-xfer", Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(root);
|
||||
@@ -183,17 +450,30 @@ public class TransferQueueTests
|
||||
var store = new SqliteIndexStore(db, NullLogger<SqliteIndexStore>.Instance);
|
||||
await store.OpenAsync();
|
||||
var shell = new GateShell();
|
||||
var queue = new TransferQueue(shell, new DiskEnum(), store, NullLogger<TransferQueue>.Instance);
|
||||
var volumes = new ControlledVolumes();
|
||||
var queue = new TransferQueue(
|
||||
new NativeFileOperationExecutor(shell, new DiskEnum(), archives, hydration),
|
||||
store,
|
||||
volumes,
|
||||
NullLogger<TransferQueue>.Instance);
|
||||
return new Harness
|
||||
{
|
||||
Queue = queue,
|
||||
Shell = shell,
|
||||
Store = store,
|
||||
Volumes = volumes,
|
||||
Dest = dest,
|
||||
Root = root
|
||||
};
|
||||
}
|
||||
|
||||
public TransferQueue CreateQueue()
|
||||
=> new(
|
||||
new NativeFileOperationExecutor(Shell, new DiskEnum()),
|
||||
Store,
|
||||
Volumes,
|
||||
NullLogger<TransferQueue>.Instance);
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await Store.DisposeAsync();
|
||||
@@ -236,11 +516,21 @@ internal sealed class GateShell : IShellFileOperations
|
||||
public List<string> Copied { get; } = [];
|
||||
public Action<string>? OnCopy { get; set; }
|
||||
public Func<bool>? PauseRequested { get; private set; }
|
||||
public int FailRemaining { get; set; }
|
||||
public string? FailError { get; set; }
|
||||
public int EmptyCount { get; private set; }
|
||||
|
||||
public void Open(string path) { }
|
||||
public bool DeleteToRecycleBin(IReadOnlyList<string> paths, out string? error) => Delete(paths, true, out error);
|
||||
public bool Delete(IReadOnlyList<string> paths, bool recycle, out string? error) { error = null; return true; }
|
||||
public bool CreateShortcut(string targetPath, string shortcutPath, out string? error) { error = null; return true; }
|
||||
public bool CreateShortcut(string targetPath, string shortcutPath, out string? error)
|
||||
{ error = null; return true; }
|
||||
public bool EmptyRecycleBin(out string? error)
|
||||
{
|
||||
EmptyCount++;
|
||||
error = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool CopyFileWithProgress(string source, string destination, bool overwrite, IProgress<long>? progress, CancellationToken cancellationToken, out string? error, Func<bool>? pauseRequested = null)
|
||||
{
|
||||
@@ -260,6 +550,13 @@ internal sealed class GateShell : IShellFileOperations
|
||||
return false;
|
||||
}
|
||||
|
||||
if (FailRemaining > 0)
|
||||
{
|
||||
FailRemaining--;
|
||||
error = FailError ?? "failed";
|
||||
return false;
|
||||
}
|
||||
|
||||
Copied.Add(source);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(destination)!);
|
||||
System.IO.File.Copy(source, destination, overwrite);
|
||||
@@ -271,3 +568,71 @@ internal sealed class GateShell : IShellFileOperations
|
||||
public bool MoveFileWithProgress(string source, string destination, bool overwrite, IProgress<long>? progress, CancellationToken cancellationToken, out string? error, Func<bool>? pauseRequested = null)
|
||||
=> CopyFileWithProgress(source, destination, overwrite, progress, cancellationToken, out error, pauseRequested);
|
||||
}
|
||||
|
||||
internal sealed class ControlledVolumes : IVolumeService
|
||||
{
|
||||
public bool Reachable { get; set; } = true;
|
||||
|
||||
public IReadOnlyList<VolumeFingerprint> EnumerateOnlineVolumes() => [];
|
||||
|
||||
public VolumeFingerprint? Probe(string path) => null;
|
||||
|
||||
public VolumeSpace GetSpace(string path) => default;
|
||||
|
||||
public bool IsPathReachable(string path) => Reachable;
|
||||
}
|
||||
|
||||
internal sealed class FakeArchiveExecutor : IArchiveExecutor
|
||||
{
|
||||
public bool IsAvailable { get; set; } = true;
|
||||
public string MissingHint => SevenZipLocator.MissingHint;
|
||||
public TimeSpan ExtractDelay { get; set; }
|
||||
public TaskCompletionSource Started { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
public async Task ExtractAsync(
|
||||
string archivePath,
|
||||
string destinationDirectory,
|
||||
IProgress<ArchiveProgress>? progress,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Started.TrySetResult();
|
||||
if (ExtractDelay > TimeSpan.Zero)
|
||||
{
|
||||
await Task.Delay(ExtractDelay, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(destinationDirectory);
|
||||
await File.WriteAllTextAsync(Path.Combine(destinationDirectory, "out.txt"), "ok", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
progress?.Report(new ArchiveProgress(100, 1, "out.txt"));
|
||||
}
|
||||
|
||||
public Task CompressAsync(
|
||||
IReadOnlyList<string> sources,
|
||||
string archivePath,
|
||||
ArchiveFormat format,
|
||||
IProgress<ArchiveProgress>? progress,
|
||||
CancellationToken cancellationToken)
|
||||
=> Task.CompletedTask;
|
||||
|
||||
public Task AddAsync(
|
||||
string archivePath,
|
||||
IReadOnlyList<string> sources,
|
||||
IProgress<ArchiveProgress>? progress,
|
||||
CancellationToken cancellationToken)
|
||||
=> Task.CompletedTask;
|
||||
|
||||
public Task VerifyAsync(
|
||||
string archivePath,
|
||||
IProgress<ArchiveProgress>? progress,
|
||||
CancellationToken cancellationToken)
|
||||
=> Task.CompletedTask;
|
||||
}
|
||||
|
||||
internal sealed class AlwaysHydrate : IHydrationGuard
|
||||
{
|
||||
public bool WouldHydrateOnRead(FileSystemItem item) => true;
|
||||
public bool WouldHydrateOnRead(int attributes, CloudAvailability? availability) => true;
|
||||
public Task<bool> WouldHydrateOnReadAsync(string path, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(true);
|
||||
}
|
||||
|
||||
@@ -191,3 +191,239 @@ public class EntryAndSearchTests
|
||||
Assert.All(work, w => Assert.Equal(100, w.SizeBytes));
|
||||
}
|
||||
}
|
||||
|
||||
public class TransferStoreTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Incomplete_jobs_roundtrip_and_history_is_append_only()
|
||||
{
|
||||
await using var store = await Stores.Open();
|
||||
var queued = new TransferJob
|
||||
{
|
||||
Op = TransferOp.Copy,
|
||||
SourcePath = @"D:\src\a.txt",
|
||||
DestinationPath = @"E:\dst\a.txt",
|
||||
Status = TransferStatus.Queued,
|
||||
CreatedUtc = DateTimeOffset.UtcNow,
|
||||
RetryCount = 0,
|
||||
WaitReason = null
|
||||
};
|
||||
queued.Id = await store.Transfers.InsertAsync(queued);
|
||||
Assert.True(queued.Id > 0);
|
||||
Assert.True(queued.SortOrder > 0);
|
||||
|
||||
queued.Status = TransferStatus.Failed;
|
||||
queued.Error = "disk full";
|
||||
queued.RetryCount = 1;
|
||||
queued.BytesDone = 12;
|
||||
queued.CurrentPath = @"D:\src\a.txt";
|
||||
await store.Transfers.UpdateAsync(queued);
|
||||
|
||||
var incomplete = await store.Transfers.GetIncompleteAsync();
|
||||
var loaded = Assert.Single(incomplete);
|
||||
Assert.Equal(queued.Id, loaded.Id);
|
||||
Assert.Equal(TransferStatus.Failed, loaded.Status);
|
||||
Assert.Equal("disk full", loaded.Error);
|
||||
Assert.Equal(1, loaded.RetryCount);
|
||||
Assert.Equal(12, loaded.BytesDone);
|
||||
Assert.Equal(@"D:\src\a.txt", loaded.CurrentPath);
|
||||
|
||||
queued.Status = TransferStatus.Done;
|
||||
queued.Error = null;
|
||||
queued.Dismissed = true;
|
||||
await store.Transfers.UpdateAsync(queued);
|
||||
|
||||
Assert.Empty(await store.Transfers.GetIncompleteAsync());
|
||||
var history = await store.Transfers.GetHistoryAsync(10);
|
||||
var done = Assert.Single(history);
|
||||
Assert.Equal(TransferStatus.Done, done.Status);
|
||||
Assert.True(done.Dismissed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Schema_is_version_4()
|
||||
{
|
||||
await using var store = await Stores.Open();
|
||||
await store.Transfers.InsertAsync(new TransferJob
|
||||
{
|
||||
Op = TransferOp.Delete,
|
||||
SourcePath = @"C:\temp\a.txt",
|
||||
DestinationPath = "recycle",
|
||||
Status = TransferStatus.Queued,
|
||||
CreatedUtc = DateTimeOffset.UtcNow,
|
||||
AdditionalSources = [@"C:\temp\a.txt"]
|
||||
});
|
||||
var loaded = Assert.Single(await store.Transfers.GetIncompleteAsync());
|
||||
Assert.Equal(TransferOp.Delete, loaded.Op);
|
||||
Assert.Equal([@"C:\temp\a.txt"], loaded.AdditionalSources);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Waiting_jobs_are_incomplete_not_history()
|
||||
{
|
||||
await using var store = await Stores.Open();
|
||||
var job = new TransferJob
|
||||
{
|
||||
Op = TransferOp.Move,
|
||||
SourcePath = @"\\nas\share\file.bin",
|
||||
DestinationPath = @"F:\backup\file.bin",
|
||||
Status = TransferStatus.Waiting,
|
||||
WaitReason = "Destination unavailable",
|
||||
CreatedUtc = DateTimeOffset.UtcNow
|
||||
};
|
||||
job.Id = await store.Transfers.InsertAsync(job);
|
||||
var incomplete = Assert.Single(await store.Transfers.GetIncompleteAsync());
|
||||
Assert.Equal(TransferStatus.Waiting, incomplete.Status);
|
||||
Assert.Equal("Destination unavailable", incomplete.WaitReason);
|
||||
Assert.Empty(await store.Transfers.GetHistoryAsync(10));
|
||||
}
|
||||
}
|
||||
|
||||
public class FileRelationTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Relations_roundtrip_and_normalize_entry_order()
|
||||
{
|
||||
await using var store = await Stores.Open();
|
||||
var source = await store.AddSourceAsync(@"C:\d");
|
||||
var a = await store.Entries.UpsertAsync(new IndexEntry
|
||||
{
|
||||
SourceId = source.Id,
|
||||
Name = "a.bin",
|
||||
NameNorm = "a.bin",
|
||||
PathRel = "a.bin",
|
||||
SizeBytes = 8,
|
||||
LastSeenUtc = DateTimeOffset.UtcNow
|
||||
});
|
||||
var b = await store.Entries.UpsertAsync(new IndexEntry
|
||||
{
|
||||
SourceId = source.Id,
|
||||
Name = "b.bin",
|
||||
NameNorm = "b.bin",
|
||||
PathRel = "b.bin",
|
||||
SizeBytes = 8,
|
||||
LastSeenUtc = DateTimeOffset.UtcNow
|
||||
});
|
||||
|
||||
await store.Relations.UpsertAsync(new FileRelation
|
||||
{
|
||||
LeftEntryId = b,
|
||||
RightEntryId = a,
|
||||
Kind = FileRelationKind.IntentionalDuplicate,
|
||||
Origin = FileRelationOrigin.User,
|
||||
CreatedUtc = DateTimeOffset.UtcNow
|
||||
});
|
||||
|
||||
var relation = Assert.Single(await store.Relations.GetAmongAsync([a, b]));
|
||||
Assert.Equal(Math.Min(a, b), relation.LeftEntryId);
|
||||
Assert.Equal(Math.Max(a, b), relation.RightEntryId);
|
||||
Assert.Equal(FileRelationKind.IntentionalDuplicate, relation.Kind);
|
||||
|
||||
await store.Relations.DeleteAmongAsync([a, b], [FileRelationKind.IntentionalDuplicate]);
|
||||
Assert.Empty(await store.Relations.GetAmongAsync([a, b]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Rename_batch_roundtrip_and_mark_undone()
|
||||
{
|
||||
await using var store = await Stores.Open();
|
||||
var id = await store.RenameBatches.CreateAsync(
|
||||
[
|
||||
new RenameBatchItem(@"C:\a\old.txt", @"C:\a\new.txt", 0),
|
||||
new RenameBatchItem(@"C:\a\one.txt", @"C:\a\two.txt", 1)
|
||||
]);
|
||||
var batch = await store.RenameBatches.GetLatestUndoableAsync();
|
||||
Assert.NotNull(batch);
|
||||
Assert.Equal(id, batch.Id);
|
||||
Assert.Equal(2, batch.Items.Count);
|
||||
Assert.Equal(@"C:\a\old.txt", batch.Items[0].OldPath);
|
||||
Assert.Equal(@"C:\a\two.txt", batch.Items[1].NewPath);
|
||||
await store.RenameBatches.MarkUndoneAsync(id);
|
||||
Assert.Null(await store.RenameBatches.GetLatestUndoableAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Sync_profile_roundtrip()
|
||||
{
|
||||
await using var store = await Stores.Open();
|
||||
var id = await store.SyncProfiles.UpsertAsync(new SyncProfile
|
||||
{
|
||||
Name = "Photos",
|
||||
SourcePath = @"C:\src",
|
||||
DestPath = @"D:\dst",
|
||||
Mode = SyncMode.CopyUpdate,
|
||||
Excludes = "*.tmp",
|
||||
AutoRun = true,
|
||||
SourceVolumeGuid = @"{src}",
|
||||
DestVolumeGuid = @"{dst}",
|
||||
CreatedUtc = DateTimeOffset.Parse("2024-06-01T12:00:00Z")
|
||||
});
|
||||
Assert.True(id > 0);
|
||||
var loaded = Assert.Single(await store.SyncProfiles.ListAsync());
|
||||
Assert.Equal(id, loaded.Id);
|
||||
Assert.Equal("Photos", loaded.Name);
|
||||
Assert.Equal(@"D:\dst", loaded.DestPath);
|
||||
Assert.Equal(SyncMode.CopyUpdate, loaded.Mode);
|
||||
Assert.Equal("*.tmp", loaded.Excludes);
|
||||
Assert.True(loaded.AutoRun);
|
||||
Assert.Equal(@"{dst}", loaded.DestVolumeGuid);
|
||||
|
||||
loaded.Mode = SyncMode.Mirror;
|
||||
loaded.AutoRun = false;
|
||||
loaded.LastStatus = "Queued 1 copy";
|
||||
await store.SyncProfiles.UpsertAsync(loaded);
|
||||
var updated = await store.SyncProfiles.GetAsync(id);
|
||||
Assert.Equal(SyncMode.Mirror, updated!.Mode);
|
||||
Assert.False(updated.AutoRun);
|
||||
Assert.Equal("Queued 1 copy", updated.LastStatus);
|
||||
|
||||
await store.SyncProfiles.DeleteAsync(id);
|
||||
Assert.Empty(await store.SyncProfiles.ListAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Operation_profile_roundtrip()
|
||||
{
|
||||
await using var store = await Stores.Open();
|
||||
var id = await store.OperationProfiles.UpsertAsync(new OperationProfile
|
||||
{
|
||||
Name = "Archive folder",
|
||||
SourcePath = @"C:\src",
|
||||
DestPath = @"D:\dst",
|
||||
RequireGitClean = true,
|
||||
DoCompress = true,
|
||||
ArchiveFormat = ArchiveFormat.SevenZip,
|
||||
DoCopy = false,
|
||||
DoRename = true,
|
||||
RenamePrefix = "x_",
|
||||
Excludes = ".git\nbin",
|
||||
AutoRun = false,
|
||||
SourceVolumeGuid = @"{src}",
|
||||
DestVolumeGuid = @"{dst}",
|
||||
IsBuiltIn = true,
|
||||
CreatedUtc = DateTimeOffset.Parse("2024-06-01T12:00:00Z")
|
||||
});
|
||||
Assert.True(id > 0);
|
||||
var loaded = Assert.Single(await store.OperationProfiles.ListAsync());
|
||||
Assert.Equal(id, loaded.Id);
|
||||
Assert.Equal("Archive folder", loaded.Name);
|
||||
Assert.True(loaded.RequireGitClean);
|
||||
Assert.True(loaded.DoCompress);
|
||||
Assert.Equal(ArchiveFormat.SevenZip, loaded.ArchiveFormat);
|
||||
Assert.True(loaded.DoRename);
|
||||
Assert.Equal("x_", loaded.RenamePrefix);
|
||||
Assert.Equal(".git\nbin", loaded.Excludes);
|
||||
Assert.True(loaded.IsBuiltIn);
|
||||
Assert.Equal(@"{dst}", loaded.DestVolumeGuid);
|
||||
|
||||
loaded.DoCopy = true;
|
||||
loaded.LastStatus = "Queued 1 compress";
|
||||
await store.OperationProfiles.UpsertAsync(loaded);
|
||||
var updated = await store.OperationProfiles.GetAsync(id);
|
||||
Assert.True(updated!.DoCopy);
|
||||
Assert.Equal("Queued 1 compress", updated.LastStatus);
|
||||
|
||||
await store.OperationProfiles.DeleteAsync(id);
|
||||
Assert.Empty(await store.OperationProfiles.ListAsync());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user