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

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