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:
2026-08-24 15:04:04 +02:00
parent 9bf451932f
commit a3c54bbb03
127 changed files with 14748 additions and 633 deletions

View File

@@ -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
};
}

View 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; }
}

View File

@@ -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; } = [];

View File

@@ -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);

View File

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

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

View 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));
}

View 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);
}
}

View 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));
}
}

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

View 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);
}
}

View File

@@ -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

View 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;
}
}

View File

@@ -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

View File

@@ -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;
}

View File

@@ -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; }
}

View 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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}

View File

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