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:
373
tests/Explorer.Application.Tests/BrowseHydrationTests.cs
Normal file
373
tests/Explorer.Application.Tests/BrowseHydrationTests.cs
Normal file
@@ -0,0 +1,373 @@
|
||||
using Explorer.Application;
|
||||
using Explorer.Domain;
|
||||
using Explorer.Domain.Abstractions;
|
||||
using Explorer.Plugin.Abstractions;
|
||||
using Explorer.Storage.Sqlite;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace Explorer.Application.Tests;
|
||||
|
||||
public class BrowseHydrationTests
|
||||
{
|
||||
[Fact]
|
||||
public void Visible_rows_are_hydrated_before_distant_rows()
|
||||
{
|
||||
var items = Enumerable.Range(0, 100)
|
||||
.Select(i => new FileSystemItem { FullPath = $@"C:\f{i}", Name = $"f{i}" })
|
||||
.ToList();
|
||||
var ordered = BrowseHydration.Prioritize(items, [@"C:\f50"]);
|
||||
Assert.Equal(@"C:\f50", ordered[0].FullPath);
|
||||
Assert.Contains(ordered.Take(BrowseHydration.NearbyWindow + 1), i => i.FullPath == @"C:\f49");
|
||||
Assert.Equal(@"C:\f99", ordered[^1].FullPath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Network_and_cloud_use_tighter_batches_than_local()
|
||||
{
|
||||
Assert.Equal(BrowseHydration.LocalProviderBatch, BrowseHydration.ProviderBatchSize(SourceKind.NtfsLocal, false));
|
||||
Assert.Equal(BrowseHydration.ConstrainedProviderBatch, BrowseHydration.ProviderBatchSize(SourceKind.Smb, false));
|
||||
Assert.Equal(BrowseHydration.ConstrainedProviderBatch, BrowseHydration.ProviderBatchSize(SourceKind.NtfsLocal, true));
|
||||
Assert.Equal(BrowseHydration.ConstrainedWorkers, BrowseHydration.WorkerCount(SourceKind.Smb, false));
|
||||
Assert.Equal(BrowseHydration.LocalWorkers, BrowseHydration.WorkerCount(SourceKind.NtfsLocal, false));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Size_sort_is_deferred_until_folder_metadata_is_ready()
|
||||
{
|
||||
Assert.True(FolderListingSort.ShouldDeferAutoSort("Size", enumerationComplete: false, sizeMetadataReady: true));
|
||||
Assert.True(FolderListingSort.ShouldDeferAutoSort("Size", enumerationComplete: true, sizeMetadataReady: false));
|
||||
Assert.False(FolderListingSort.ShouldDeferAutoSort("Size", enumerationComplete: true, sizeMetadataReady: true));
|
||||
Assert.False(FolderListingSort.ShouldDeferAutoSort("Name", enumerationComplete: true, sizeMetadataReady: false));
|
||||
Assert.False(FolderListingSort.DependsOnIncompleteMetadata("Modified"));
|
||||
Assert.True(FolderListingSort.DependsOnIncompleteMetadata("Size"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Name_sort_keeps_folders_first()
|
||||
{
|
||||
FileSystemItem[] items =
|
||||
[
|
||||
new() { FullPath = @"C:\z.txt", Name = "z.txt" },
|
||||
new() { FullPath = @"C:\A", Name = "A", IsDirectory = true },
|
||||
new() { FullPath = @"C:\b.txt", Name = "b.txt" }
|
||||
];
|
||||
var ordered = FolderListingSort.Order(items, "Name", descending: false).Select(i => i.Name).ToList();
|
||||
Assert.Equal(["A", "b.txt", "z.txt"], ordered);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Progressive_listing_publishes_rows_before_provider_enrichment()
|
||||
{
|
||||
var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var (browse, store, _) = await CreateAsync(new GatedCloudProvider(@"C:\", gate));
|
||||
try
|
||||
{
|
||||
await using var it = browse.ListProgressiveAsync(@"C:\").GetAsyncEnumerator();
|
||||
Assert.True(await it.MoveNextAsync());
|
||||
Assert.NotEmpty(it.Current.Added);
|
||||
Assert.Contains(it.Current.Added, i => i.Name == "Movies");
|
||||
Assert.All(it.Current.Added, i => Assert.Null(i.Cloud));
|
||||
Assert.False(it.Current.HydrationComplete);
|
||||
Assert.False(gate.Task.IsCompleted);
|
||||
|
||||
gate.SetResult();
|
||||
var sawCloud = false;
|
||||
var complete = it.Current.HydrationComplete;
|
||||
while (!complete && await it.MoveNextAsync())
|
||||
{
|
||||
sawCloud |= it.Current.Updated.Any(u => u.Cloud is not null) || it.Current.Added.Any(a => a.Cloud is not null);
|
||||
complete = it.Current.HydrationComplete;
|
||||
}
|
||||
|
||||
Assert.True(complete);
|
||||
Assert.True(sawCloud);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await store.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Progressive_listing_cancels_without_applying_stale_provider_results()
|
||||
{
|
||||
var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var provider = new GatedCloudProvider(@"C:\", gate);
|
||||
var (browse, store, _) = await CreateAsync(provider);
|
||||
try
|
||||
{
|
||||
using var cts = new CancellationTokenSource();
|
||||
await using var it = browse.ListProgressiveAsync(@"C:\", viewport: null, cts.Token).GetAsyncEnumerator();
|
||||
Assert.True(await it.MoveNextAsync());
|
||||
cts.Cancel();
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(async () =>
|
||||
{
|
||||
while (await it.MoveNextAsync())
|
||||
{
|
||||
}
|
||||
});
|
||||
Assert.Equal(0, provider.CompletedCalls);
|
||||
gate.SetResult();
|
||||
await Task.Delay(50);
|
||||
Assert.Equal(0, provider.CompletedCalls);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await store.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Provider_enrichment_uses_bounded_batches()
|
||||
{
|
||||
var provider = new RecordingProvider(@"C:\");
|
||||
var items = Enumerable.Range(0, 80)
|
||||
.Select(i => new FileSystemItem { FullPath = $@"C:\f{i}.txt", Name = $"f{i}.txt", SizeBytes = i })
|
||||
.ToList();
|
||||
var (browse, store, _) = await CreateAsync(provider, new FixedEnumerator(items));
|
||||
try
|
||||
{
|
||||
var listing = await browse.ListAsync(@"C:\");
|
||||
Assert.Equal(80, listing.Items.Count);
|
||||
Assert.NotEmpty(provider.BatchSizes);
|
||||
Assert.All(provider.BatchSizes, size => Assert.InRange(size, 1, BrowseHydration.ConstrainedProviderBatch));
|
||||
Assert.Equal(80, provider.BatchSizes.Sum());
|
||||
}
|
||||
finally
|
||||
{
|
||||
await store.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Live_listing_does_not_open_items_with_GetItem()
|
||||
{
|
||||
var enumerator = new GuardedEnumerator();
|
||||
var (browse, store, _) = await CreateAsync(enumerator: enumerator);
|
||||
try
|
||||
{
|
||||
var listing = await browse.ListAsync(@"C:\");
|
||||
Assert.Contains(listing.Items, i => i.Name == "Movies");
|
||||
Assert.Equal(0, enumerator.GetItemCalls);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await store.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListAsync_still_returns_index_folder_sizes()
|
||||
{
|
||||
var (browse, store, _) = await CreateAsync();
|
||||
try
|
||||
{
|
||||
var source = (await store.Sources.GetAllAsync()).Single();
|
||||
source.LastIndexedUtc = DateTimeOffset.UtcNow;
|
||||
await store.Sources.UpsertAsync(source);
|
||||
var root = new IndexEntry
|
||||
{
|
||||
SourceId = source.Id,
|
||||
Name = "C:",
|
||||
NameNorm = "c:",
|
||||
IsDirectory = true,
|
||||
PathRel = "",
|
||||
LastSeenUtc = DateTimeOffset.UtcNow
|
||||
};
|
||||
root.Id = await store.Entries.UpsertAsync(root);
|
||||
await store.Entries.UpsertAsync(new IndexEntry
|
||||
{
|
||||
SourceId = source.Id,
|
||||
ParentId = root.Id,
|
||||
Name = "Movies",
|
||||
NameNorm = "movies",
|
||||
IsDirectory = true,
|
||||
PathRel = "Movies",
|
||||
LastSeenUtc = DateTimeOffset.UtcNow,
|
||||
AggregateSize = 200
|
||||
});
|
||||
|
||||
var listing = await browse.ListAsync(@"C:\");
|
||||
var movies = Assert.Single(listing.Items, i => i.Name == "Movies");
|
||||
Assert.Equal(200, movies.SizeBytes);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await store.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<(BrowseService Browse, SqliteIndexStore Store, IAppEnvironment Env)> CreateAsync(
|
||||
IStorageProvider? provider = null,
|
||||
IFileSystemEnumerator? enumerator = null)
|
||||
{
|
||||
var db = Path.Combine(Path.GetTempPath(), "ew-browse-hyd", Guid.NewGuid().ToString("N"), "index.db");
|
||||
var store = new SqliteIndexStore(db, NullLogger<SqliteIndexStore>.Instance);
|
||||
await store.OpenAsync();
|
||||
var volumes = new HydrationVolumes
|
||||
{
|
||||
Online =
|
||||
[
|
||||
new VolumeFingerprint
|
||||
{
|
||||
Kind = SourceKind.NtfsLocal,
|
||||
RootPath = @"C:\",
|
||||
DisplayName = "C:",
|
||||
VolumeSerial = 1,
|
||||
CapacityBytes = 1_000_000_000,
|
||||
FreeBytes = 250_000_000
|
||||
}
|
||||
]
|
||||
};
|
||||
var env = new HydrationEnv(Path.GetDirectoryName(db)!);
|
||||
var sources = new SourceManager(store, volumes, env, new SystemClock(), NullLogger<SourceManager>.Instance);
|
||||
await sources.InitializeAsync();
|
||||
var providers = provider is null ? Array.Empty<IStorageProvider>() : new[] { provider };
|
||||
var browse = new BrowseService(
|
||||
enumerator ?? new GuardedEnumerator(),
|
||||
volumes,
|
||||
store,
|
||||
sources,
|
||||
new StorageProviderRegistry(providers, NullLogger<StorageProviderRegistry>.Instance),
|
||||
new CloudPlaceStore(env),
|
||||
new UiPreferencesStore(env));
|
||||
return (browse, store, env);
|
||||
}
|
||||
}
|
||||
|
||||
file sealed class GuardedEnumerator : IFileSystemEnumerator
|
||||
{
|
||||
public int GetItemCalls { get; private set; }
|
||||
|
||||
public IEnumerable<FileSystemItem> EnumerateChildren(string directoryPath)
|
||||
=> EnumerateChildrenSafe(directoryPath, out _);
|
||||
|
||||
public FileSystemItem? GetItem(string path)
|
||||
{
|
||||
GetItemCalls++;
|
||||
throw new InvalidOperationException("GetItem must not run during folder listing.");
|
||||
}
|
||||
|
||||
public IReadOnlyList<FileSystemItem> EnumerateChildrenSafe(string directoryPath, out string? error)
|
||||
{
|
||||
error = null;
|
||||
return
|
||||
[
|
||||
new FileSystemItem { FullPath = Path.Combine(directoryPath, "Movies"), Name = "Movies", IsDirectory = true },
|
||||
new FileSystemItem { FullPath = Path.Combine(directoryPath, "loose.txt"), Name = "loose.txt", SizeBytes = 10 }
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
file sealed class FixedEnumerator(IReadOnlyList<FileSystemItem> items) : IFileSystemEnumerator
|
||||
{
|
||||
public IEnumerable<FileSystemItem> EnumerateChildren(string directoryPath)
|
||||
=> EnumerateChildrenSafe(directoryPath, out _);
|
||||
|
||||
public FileSystemItem? GetItem(string path) => throw new InvalidOperationException("GetItem must not run during folder listing.");
|
||||
|
||||
public IReadOnlyList<FileSystemItem> EnumerateChildrenSafe(string directoryPath, out string? error)
|
||||
{
|
||||
error = null;
|
||||
return items;
|
||||
}
|
||||
}
|
||||
|
||||
file sealed class GatedCloudProvider(string root, TaskCompletionSource gate) : IStorageProvider
|
||||
{
|
||||
public int CompletedCalls;
|
||||
|
||||
public ProviderManifest Manifest { get; } = new("onedrive", "OneDrive", "1", ProviderIsolation.InProcess);
|
||||
public ProviderCapability GetCapabilities() => ProviderCapability.CloudState;
|
||||
public bool TryMatchRoot(string path) => path.StartsWith(root.TrimEnd('\\'), StringComparison.OrdinalIgnoreCase);
|
||||
public IReadOnlyList<ProviderPlace> GetPlaces() => [];
|
||||
|
||||
public async Task<IReadOnlyList<ProviderItemState>> GetItemStatesAsync(
|
||||
IReadOnlyList<string> paths,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await gate.Task.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
Interlocked.Increment(ref CompletedCalls);
|
||||
return paths.Select(p => new ProviderItemState(
|
||||
"onedrive",
|
||||
p,
|
||||
Plugin.Abstractions.CloudAvailability.OnlineOnly,
|
||||
10,
|
||||
0,
|
||||
true,
|
||||
true,
|
||||
"Online-only",
|
||||
null)).ToList();
|
||||
}
|
||||
|
||||
public Task<ProviderActionResult> TryInvokeAsync(ProviderActionRequest request, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(new ProviderActionResult(ProviderActionStatus.Unsupported));
|
||||
|
||||
public Task<ProviderQuota?> TryGetQuotaAsync(string rootPath, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<ProviderQuota?>(null);
|
||||
}
|
||||
|
||||
file sealed class RecordingProvider(string root) : IStorageProvider
|
||||
{
|
||||
public List<int> BatchSizes { get; } = [];
|
||||
|
||||
public ProviderManifest Manifest { get; } = new("onedrive", "OneDrive", "1", ProviderIsolation.InProcess);
|
||||
public ProviderCapability GetCapabilities() => ProviderCapability.CloudState;
|
||||
public bool TryMatchRoot(string path) => path.StartsWith(root.TrimEnd('\\'), StringComparison.OrdinalIgnoreCase);
|
||||
public IReadOnlyList<ProviderPlace> GetPlaces() => [];
|
||||
|
||||
public Task<IReadOnlyList<ProviderItemState>> GetItemStatesAsync(
|
||||
IReadOnlyList<string> paths,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
BatchSizes.Add(paths.Count);
|
||||
return Task.FromResult<IReadOnlyList<ProviderItemState>>(
|
||||
paths.Select(p => new ProviderItemState(
|
||||
"onedrive",
|
||||
p,
|
||||
Plugin.Abstractions.CloudAvailability.LocallyAvailable,
|
||||
1,
|
||||
1,
|
||||
false,
|
||||
false,
|
||||
null,
|
||||
null)).ToList());
|
||||
}
|
||||
|
||||
public Task<ProviderActionResult> TryInvokeAsync(ProviderActionRequest request, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(new ProviderActionResult(ProviderActionStatus.Unsupported));
|
||||
|
||||
public Task<ProviderQuota?> TryGetQuotaAsync(string rootPath, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<ProviderQuota?>(null);
|
||||
}
|
||||
|
||||
file sealed class HydrationVolumes : IVolumeService
|
||||
{
|
||||
public List<VolumeFingerprint> Online { get; set; } = [];
|
||||
public IReadOnlyList<VolumeFingerprint> EnumerateOnlineVolumes() => Online;
|
||||
public VolumeFingerprint? Probe(string path)
|
||||
=> Online.FirstOrDefault(v => path.StartsWith(v.RootPath.TrimEnd('\\'), StringComparison.OrdinalIgnoreCase));
|
||||
public bool IsPathReachable(string path)
|
||||
=> Online.Any(v => path.StartsWith(v.RootPath.TrimEnd('\\'), StringComparison.OrdinalIgnoreCase));
|
||||
public VolumeSpace GetSpace(string path)
|
||||
{
|
||||
var fp = Probe(path);
|
||||
return new VolumeSpace(fp?.CapacityBytes, fp?.FreeBytes);
|
||||
}
|
||||
}
|
||||
|
||||
file sealed class HydrationEnv : IAppEnvironment
|
||||
{
|
||||
public HydrationEnv(string dir)
|
||||
{
|
||||
DataDirectory = dir;
|
||||
Directory.CreateDirectory(dir);
|
||||
DatabasePath = Path.Combine(dir, "index.db");
|
||||
LogDirectory = Path.Combine(dir, "logs");
|
||||
Directory.CreateDirectory(LogDirectory);
|
||||
}
|
||||
|
||||
public string DataDirectory { get; }
|
||||
public string DatabasePath { get; }
|
||||
public string LogDirectory { get; }
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
using Explorer.Application;
|
||||
using Explorer.Domain;
|
||||
using Explorer.Domain.Abstractions;
|
||||
using Explorer.Plugin.Abstractions;
|
||||
using Explorer.Storage.Sqlite;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
@@ -28,11 +29,61 @@ public class BrowseServiceTests
|
||||
await store.Entries.UpsertAsync(root);
|
||||
|
||||
var listing = await browse.ListThisPcAsync();
|
||||
var item = Assert.Single(listing.Items);
|
||||
var item = Assert.Single(listing.Items, i => i.FullPath.StartsWith(@"C:\", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.Equal(1_073_741_824, item.SizeBytes);
|
||||
Assert.Equal(250_000_000, item.FreeSpaceBytes);
|
||||
Assert.Equal(1_000_000_000, item.CapacityBytes);
|
||||
Assert.True(item.IsDirectory);
|
||||
Assert.Contains(listing.Items, i => i.FullPath == LocationRoots.RecycleBin);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task This_pc_lists_untracked_network_drives_as_importable()
|
||||
{
|
||||
var db = Path.Combine(Path.GetTempPath(), "ew-browse", Guid.NewGuid().ToString("N"), "index.db");
|
||||
var store = new SqliteIndexStore(db, NullLogger<SqliteIndexStore>.Instance);
|
||||
await store.OpenAsync();
|
||||
var volumes = new BrowseVolumes
|
||||
{
|
||||
Online =
|
||||
[
|
||||
new VolumeFingerprint
|
||||
{
|
||||
Kind = SourceKind.NtfsLocal,
|
||||
RootPath = @"C:\",
|
||||
DisplayName = "C:",
|
||||
VolumeSerial = 1,
|
||||
CapacityBytes = 1_000_000_000,
|
||||
FreeBytes = 250_000_000
|
||||
},
|
||||
new VolumeFingerprint
|
||||
{
|
||||
Kind = SourceKind.Smb,
|
||||
RootPath = @"Z:\",
|
||||
DisplayName = "Z: (Network)",
|
||||
Filesystem = "SMB",
|
||||
CapacityBytes = 2_000,
|
||||
FreeBytes = 500
|
||||
}
|
||||
]
|
||||
};
|
||||
var env = new BrowseEnv(Path.GetDirectoryName(db)!);
|
||||
var sources = new SourceManager(store, volumes, env, new SystemClock(), NullLogger<SourceManager>.Instance);
|
||||
await sources.InitializeAsync();
|
||||
var browse = new BrowseService(
|
||||
new BrowseEnumerator(),
|
||||
volumes,
|
||||
store,
|
||||
sources,
|
||||
new StorageProviderRegistry([], NullLogger<StorageProviderRegistry>.Instance),
|
||||
new CloudPlaceStore(env),
|
||||
new UiPreferencesStore(env));
|
||||
var listing = await browse.ListThisPcAsync();
|
||||
Assert.Contains(listing.Items, i => i.FullPath.StartsWith(@"C:\", StringComparison.OrdinalIgnoreCase) && !i.AvailableToImport);
|
||||
var imported = Assert.Single(listing.Items, i => i.AvailableToImport);
|
||||
Assert.Equal(@"Z:\", imported.FullPath);
|
||||
Assert.Contains("(Windows)", imported.DisplayName);
|
||||
await store.DisposeAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -72,7 +123,95 @@ public class BrowseServiceTests
|
||||
Assert.Equal(10, loose.SizeBytes);
|
||||
}
|
||||
|
||||
private static async Task<(BrowseService Browse, SqliteIndexStore Store)> CreateAsync(string root, string display)
|
||||
[Fact]
|
||||
public async Task Recycle_bin_listing_uses_catalog_summary()
|
||||
{
|
||||
var (browse, _) = await CreateAsync(@"C:\", "C:", new FakeRecycleBin(new RecycleBinSummary(3, 4096)));
|
||||
var listing = browse.ListRecycleBin();
|
||||
Assert.Equal(LocationRoots.RecycleBin, listing.Path);
|
||||
Assert.Contains("3", listing.Error);
|
||||
Assert.Contains("4 KB", listing.Error, StringComparison.OrdinalIgnoreCase);
|
||||
var item = Assert.Single(listing.Items);
|
||||
Assert.True(item.Location.IsRecycleBin);
|
||||
Assert.Equal(4096, item.SizeBytes);
|
||||
|
||||
var redirected = await browse.ListAsync(@"C:\$RECYCLE.BIN");
|
||||
Assert.Equal(LocationRoots.RecycleBin, redirected.Path);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Recycle_bin_folder_stays_hidden_when_protected_locations_are_shown()
|
||||
{
|
||||
var (browse, _) = await CreateAsync(
|
||||
@"C:\",
|
||||
"C:",
|
||||
enumerator: new RecycleFolderEnumerator(),
|
||||
configurePrefs: store => store.Save(UiPreferences.Default with { ShowProtectedSystemLocations = true }));
|
||||
var listing = await browse.ListAsync(@"C:\");
|
||||
Assert.DoesNotContain(listing.Items, i => i.Name.Equals("$RECYCLE.BIN", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.Contains(listing.Items, i => i.Name == "Movies");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Cloud_quota_overrides_volume_space_and_null_is_safe()
|
||||
{
|
||||
var db = Path.Combine(Path.GetTempPath(), "ew-browse", Guid.NewGuid().ToString("N"), "index.db");
|
||||
var store = new SqliteIndexStore(db, NullLogger<SqliteIndexStore>.Instance);
|
||||
await store.OpenAsync();
|
||||
var env = new BrowseEnv(Path.GetDirectoryName(db)!);
|
||||
var volumes = new BrowseVolumes();
|
||||
var sources = new SourceManager(store, volumes, env, new SystemClock(), NullLogger<SourceManager>.Instance);
|
||||
await sources.InitializeAsync();
|
||||
var places = new CloudPlaceStore(env);
|
||||
var cloudRoot = Path.Combine(env.DataDirectory, "OneDrive");
|
||||
Directory.CreateDirectory(cloudRoot);
|
||||
places.Add("onedrive", cloudRoot, "OneDrive");
|
||||
var quotaProvider = new QuotaProvider(cloudRoot, new ProviderQuota(cloudRoot, UsedBytes: 20, TotalBytes: 100, Label: "OneDrive"));
|
||||
var browse = new BrowseService(
|
||||
new BrowseEnumerator(),
|
||||
volumes,
|
||||
store,
|
||||
sources,
|
||||
new StorageProviderRegistry([quotaProvider], NullLogger<StorageProviderRegistry>.Instance),
|
||||
places,
|
||||
new UiPreferencesStore(env));
|
||||
var listing = await browse.ListCloudAsync();
|
||||
var item = Assert.Single(listing.Items);
|
||||
Assert.Equal(80, item.FreeSpaceBytes);
|
||||
Assert.Equal(100, item.CapacityBytes);
|
||||
|
||||
var nullSafe = new BrowseService(
|
||||
new BrowseEnumerator(),
|
||||
volumes,
|
||||
store,
|
||||
sources,
|
||||
new StorageProviderRegistry([new QuotaProvider(cloudRoot, null)], NullLogger<StorageProviderRegistry>.Instance),
|
||||
places,
|
||||
new UiPreferencesStore(env));
|
||||
var fallback = await nullSafe.ListCloudAsync();
|
||||
Assert.Null(Assert.Single(fallback.Items).FreeSpaceBytes);
|
||||
|
||||
var throwing = new BrowseService(
|
||||
new BrowseEnumerator(),
|
||||
volumes,
|
||||
store,
|
||||
sources,
|
||||
new StorageProviderRegistry(
|
||||
[new QuotaProvider(cloudRoot, null, throwOnQuota: true)],
|
||||
NullLogger<StorageProviderRegistry>.Instance),
|
||||
places,
|
||||
new UiPreferencesStore(env));
|
||||
var safe = await throwing.ListCloudAsync();
|
||||
Assert.Null(Assert.Single(safe.Items).FreeSpaceBytes);
|
||||
await store.DisposeAsync();
|
||||
}
|
||||
|
||||
private static async Task<(BrowseService Browse, SqliteIndexStore Store)> CreateAsync(
|
||||
string root,
|
||||
string display,
|
||||
IRecycleBinCatalog? recycle = null,
|
||||
IFileSystemEnumerator? enumerator = null,
|
||||
Action<UiPreferencesStore>? configurePrefs = null)
|
||||
{
|
||||
var db = Path.Combine(Path.GetTempPath(), "ew-browse", Guid.NewGuid().ToString("N"), "index.db");
|
||||
var store = new SqliteIndexStore(db, NullLogger<SqliteIndexStore>.Instance);
|
||||
@@ -95,14 +234,17 @@ public class BrowseServiceTests
|
||||
var env = new BrowseEnv(Path.GetDirectoryName(db)!);
|
||||
var sources = new SourceManager(store, volumes, env, new SystemClock(), NullLogger<SourceManager>.Instance);
|
||||
await sources.InitializeAsync();
|
||||
var prefs = new UiPreferencesStore(env);
|
||||
configurePrefs?.Invoke(prefs);
|
||||
var browse = new BrowseService(
|
||||
new BrowseEnumerator(),
|
||||
enumerator ?? new BrowseEnumerator(),
|
||||
volumes,
|
||||
store,
|
||||
sources,
|
||||
new StorageProviderRegistry([], NullLogger<StorageProviderRegistry>.Instance),
|
||||
new CloudPlaceStore(env),
|
||||
new UiPreferencesStore(env));
|
||||
prefs,
|
||||
recycle: recycle);
|
||||
return (browse, store);
|
||||
}
|
||||
}
|
||||
@@ -125,6 +267,50 @@ file sealed class BrowseEnumerator : IFileSystemEnumerator
|
||||
}
|
||||
}
|
||||
|
||||
file sealed class RecycleFolderEnumerator : IFileSystemEnumerator
|
||||
{
|
||||
public IEnumerable<FileSystemItem> EnumerateChildren(string directoryPath)
|
||||
=> EnumerateChildrenSafe(directoryPath, out _);
|
||||
|
||||
public FileSystemItem? GetItem(string path) => null;
|
||||
|
||||
public IReadOnlyList<FileSystemItem> EnumerateChildrenSafe(string directoryPath, out string? error)
|
||||
{
|
||||
error = null;
|
||||
return
|
||||
[
|
||||
new FileSystemItem
|
||||
{
|
||||
FullPath = Path.Combine(directoryPath, "$RECYCLE.BIN"),
|
||||
Name = "$RECYCLE.BIN",
|
||||
IsDirectory = true,
|
||||
Attributes = AttributeFlags.Directory | AttributeFlags.Hidden | AttributeFlags.System
|
||||
},
|
||||
new FileSystemItem { FullPath = Path.Combine(directoryPath, "Movies"), Name = "Movies", IsDirectory = true }
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
file sealed class FakeRecycleBin(RecycleBinSummary summary) : IRecycleBinCatalog
|
||||
{
|
||||
public RecycleBinSummary? TrySummarize(string? rootPath = null) => summary;
|
||||
}
|
||||
|
||||
file sealed class QuotaProvider(string root, ProviderQuota? quota, bool throwOnQuota = false) : IStorageProvider
|
||||
{
|
||||
public ProviderManifest Manifest { get; } = new("onedrive", "OneDrive", "1", ProviderIsolation.InProcess);
|
||||
public ProviderCapability GetCapabilities() => ProviderCapability.Quota;
|
||||
public bool TryMatchRoot(string path) => CloudPath.IsUnder(root, path);
|
||||
public Task<IReadOnlyList<ProviderItemState>> GetItemStatesAsync(IReadOnlyList<string> paths, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<IReadOnlyList<ProviderItemState>>([]);
|
||||
public Task<ProviderActionResult> TryInvokeAsync(ProviderActionRequest request, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(new ProviderActionResult(ProviderActionStatus.Unsupported));
|
||||
public Task<ProviderQuota?> TryGetQuotaAsync(string rootPath, CancellationToken cancellationToken = default)
|
||||
=> throwOnQuota
|
||||
? throw new InvalidOperationException("quota failed")
|
||||
: Task.FromResult(quota);
|
||||
}
|
||||
|
||||
file sealed class BrowseVolumes : IVolumeService
|
||||
{
|
||||
public List<VolumeFingerprint> Online { get; set; } = [];
|
||||
|
||||
@@ -10,6 +10,13 @@ namespace Explorer.Application.Tests;
|
||||
|
||||
public class CloudProviderTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Quota_returns_null_when_provider_throws()
|
||||
{
|
||||
var registry = new StorageProviderRegistry([new QuotaThrowingProvider()], NullLogger<StorageProviderRegistry>.Instance);
|
||||
Assert.Null(await registry.TryGetQuotaAsync(@"C:\OneDrive"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Enrich_passes_through_when_provider_throws()
|
||||
{
|
||||
@@ -246,6 +253,19 @@ file sealed class ThrowingProvider : IStorageProvider
|
||||
=> throw new InvalidOperationException("quota failed");
|
||||
}
|
||||
|
||||
file sealed class QuotaThrowingProvider : IStorageProvider
|
||||
{
|
||||
public ProviderManifest Manifest { get; } = new("onedrive", "OneDrive", "1", ProviderIsolation.InProcess);
|
||||
public ProviderCapability GetCapabilities() => ProviderCapability.Quota;
|
||||
public bool TryMatchRoot(string path) => path.Contains("OneDrive", StringComparison.OrdinalIgnoreCase);
|
||||
public Task<IReadOnlyList<ProviderItemState>> GetItemStatesAsync(IReadOnlyList<string> paths, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<IReadOnlyList<ProviderItemState>>([]);
|
||||
public Task<ProviderActionResult> TryInvokeAsync(ProviderActionRequest request, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(new ProviderActionResult(ProviderActionStatus.Unsupported));
|
||||
public Task<ProviderQuota?> TryGetQuotaAsync(string rootPath, CancellationToken cancellationToken = default)
|
||||
=> throw new InvalidOperationException("quota failed");
|
||||
}
|
||||
|
||||
file sealed class StubProvider : IStorageProvider
|
||||
{
|
||||
public ProviderManifest Manifest { get; } = new("onedrive", "OneDrive", "1", ProviderIsolation.InProcess);
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
using Explorer.Application;
|
||||
using Explorer.Domain;
|
||||
using Explorer.Domain.Abstractions;
|
||||
|
||||
namespace Explorer.Application.Tests;
|
||||
|
||||
public class FileOperationProfilePlannerTests
|
||||
{
|
||||
private static readonly DateTimeOffset T0 = DateTimeOffset.Parse("2024-06-01T12:00:00Z");
|
||||
|
||||
[Fact]
|
||||
public void Copy_lists_copy_operations_only()
|
||||
{
|
||||
var fs = Tree()
|
||||
.Dir(@"C:\src")
|
||||
.File(@"C:\src\a.txt", 1, T0)
|
||||
.File(@"C:\src\b.txt", 2, T0)
|
||||
.Dir(@"C:\dst");
|
||||
|
||||
var plan = Build(CopyProfile(), fs, [@"C:\src"]);
|
||||
Assert.True(plan.CanEnqueue);
|
||||
Assert.All(plan.Operations, o => Assert.Equal(TransferOp.Copy, o.Op));
|
||||
Assert.Contains(plan.Operations, o => o.SourcePath == @"C:\src\a.txt" && o.DestinationPath == @"C:\dst\src\a.txt");
|
||||
Assert.Contains(plan.Operations, o => o.SourcePath == @"C:\src\b.txt" && o.DestinationPath == @"C:\dst\src\b.txt");
|
||||
Assert.Contains(plan.ProfilePreview, r => r.Action == "Copy" && r.Path == @"C:\dst\src\a.txt");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Compress_lists_one_archive_job()
|
||||
{
|
||||
var fs = Tree()
|
||||
.Dir(@"C:\src")
|
||||
.File(@"C:\src\a.txt", 1, T0)
|
||||
.File(@"C:\src\b.txt", 2, T0)
|
||||
.Dir(@"C:\dst");
|
||||
|
||||
var plan = Build(new OperationProfile
|
||||
{
|
||||
Name = "Archive",
|
||||
DestPath = @"C:\dst",
|
||||
DoCompress = true
|
||||
}, fs, [@"C:\src"]);
|
||||
Assert.True(plan.CanEnqueue);
|
||||
var compress = Assert.Single(plan.Operations);
|
||||
Assert.Equal(TransferOp.Compress, compress.Op);
|
||||
Assert.Equal(@"C:\src\a.txt|C:\src\b.txt", compress.SourcePath);
|
||||
Assert.Equal(@"C:\dst\src.7z", compress.DestinationPath);
|
||||
Assert.Contains(plan.ProfilePreview, r => r.Action == "Compress");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dirty_git_stops_before_operations()
|
||||
{
|
||||
var fs = Tree()
|
||||
.Dir(@"C:\src")
|
||||
.File(@"C:\src\a.txt", 1, T0)
|
||||
.Dir(@"C:\dst");
|
||||
var git = new GitStatus { RepoRoot = @"C:\src", Branch = "main", ModifiedCount = 1 };
|
||||
|
||||
var plan = Build(CopyProfile(git: true), fs, [@"C:\src"], git);
|
||||
Assert.False(plan.CanEnqueue);
|
||||
Assert.Empty(plan.Operations);
|
||||
Assert.Contains(plan.Issues, i => i.Message == "Working tree is not clean.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Missing_git_and_missing_repo_are_errors()
|
||||
{
|
||||
var fs = Tree().Dir(@"C:\src").File(@"C:\src\a.txt", 1, T0).Dir(@"C:\dst");
|
||||
var noGit = Build(CopyProfile(git: true), fs, [@"C:\src"], gitAvailable: false);
|
||||
Assert.Contains(noGit.Issues, i => i.Message == "git.exe is not available.");
|
||||
|
||||
var noRepo = Build(CopyProfile(git: true), fs, [@"C:\src"], git: null);
|
||||
Assert.Contains(noRepo.Issues, i => i.Message == "Source is not a Git repository.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Offline_destination_is_an_error()
|
||||
{
|
||||
var fs = Tree()
|
||||
.Dir(@"C:\src")
|
||||
.File(@"C:\src\a.txt", 1, T0)
|
||||
.Dir(@"Z:\backup");
|
||||
|
||||
var plan = Build(
|
||||
CopyProfile(@"Z:\backup"),
|
||||
fs,
|
||||
[@"C:\src"],
|
||||
reachable: path => path.StartsWith(@"C:\", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.False(plan.CanEnqueue);
|
||||
Assert.Empty(plan.Operations);
|
||||
Assert.Contains(plan.Issues, i => i.Message == "Destination is not available.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rename_then_copy_uses_new_names()
|
||||
{
|
||||
var fs = Tree()
|
||||
.Dir(@"C:\src")
|
||||
.File(@"C:\src\keep.txt", 1, T0)
|
||||
.File(@"C:\src\a.txt", 1, T0)
|
||||
.Dir(@"C:\dst");
|
||||
|
||||
var plan = Build(new OperationProfile
|
||||
{
|
||||
Name = "Rename copy",
|
||||
DestPath = @"C:\dst",
|
||||
DoCopy = true,
|
||||
DoRename = true,
|
||||
RenamePrefix = "x_"
|
||||
}, fs, [@"C:\src"]);
|
||||
Assert.True(plan.CanEnqueue);
|
||||
Assert.Contains(plan.Operations, o => o.Op == TransferOp.Rename && o.NewName == "x_keep.txt");
|
||||
Assert.Contains(plan.Operations, o =>
|
||||
o.Op == TransferOp.Copy
|
||||
&& o.SourcePath == @"C:\src\x_a.txt"
|
||||
&& o.DestinationPath == @"C:\dst\src\x_a.txt");
|
||||
Assert.Contains(plan.Operations, o =>
|
||||
o.Op == TransferOp.Copy
|
||||
&& o.SourcePath == @"C:\src\x_keep.txt"
|
||||
&& o.DestinationPath == @"C:\dst\src\x_keep.txt");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Excludes_and_online_only_files_are_skipped()
|
||||
{
|
||||
var fs = Tree()
|
||||
.Dir(@"C:\src")
|
||||
.File(@"C:\src\keep.txt", 1, T0)
|
||||
.File(@"C:\src\skip.tmp", 1, T0)
|
||||
.Dir(@"C:\src\bin")
|
||||
.File(@"C:\src\cloud.bin", 1, T0)
|
||||
.Dir(@"C:\dst");
|
||||
|
||||
var plan = Build(new OperationProfile
|
||||
{
|
||||
Name = "Copy",
|
||||
DestPath = @"C:\dst",
|
||||
DoCopy = true,
|
||||
Excludes = "*.tmp\nbin"
|
||||
}, fs, [@"C:\src"], wouldHydrate: item => item.Name == "cloud.bin");
|
||||
Assert.Contains(plan.Operations, o => o.SourcePath.EndsWith("keep.txt", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.DoesNotContain(plan.Operations, o => o.SourcePath.EndsWith("skip.tmp", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.DoesNotContain(plan.Operations, o => o.SourcePath.Contains(@"\bin", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.DoesNotContain(plan.Operations, o => o.SourcePath.EndsWith("cloud.bin", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.Contains(plan.Issues, i => i.Message.Contains("Online-only", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Missing_7zip_is_an_error()
|
||||
{
|
||||
var fs = Tree().Dir(@"C:\src").File(@"C:\src\a.txt", 1, T0).Dir(@"C:\dst");
|
||||
var plan = Build(new OperationProfile
|
||||
{
|
||||
Name = "Archive",
|
||||
DestPath = @"C:\dst",
|
||||
DoCompress = true
|
||||
}, fs, [@"C:\src"], compressAvailable: false);
|
||||
Assert.False(plan.CanEnqueue);
|
||||
Assert.Contains(plan.Issues, i => i.Message.Contains("7-Zip", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private static OperationProfile CopyProfile(string dest = @"C:\dst", bool git = false)
|
||||
=> new()
|
||||
{
|
||||
Name = "Copy",
|
||||
DestPath = dest,
|
||||
DoCopy = true,
|
||||
RequireGitClean = git
|
||||
};
|
||||
|
||||
private static OperationPlan Build(
|
||||
OperationProfile profile,
|
||||
IFileSystemEnumerator fs,
|
||||
string[] sources,
|
||||
GitStatus? git = null,
|
||||
bool gitAvailable = true,
|
||||
bool compressAvailable = true,
|
||||
Func<string, bool>? reachable = null,
|
||||
Func<FileSystemItem, bool>? wouldHydrate = null)
|
||||
=> new FileOperationProfilePlanner(new RenamePlanner()).Build(
|
||||
profile,
|
||||
sources,
|
||||
fs,
|
||||
reachable ?? (_ => true),
|
||||
git,
|
||||
gitAvailable,
|
||||
compressAvailable,
|
||||
SevenZipLocator.MissingHint,
|
||||
pathExists: _ => false,
|
||||
wouldHydrate: wouldHydrate);
|
||||
|
||||
private static TreeEnumerator Tree() => new();
|
||||
}
|
||||
240
tests/Explorer.Application.Tests/FolderSyncPlannerTests.cs
Normal file
240
tests/Explorer.Application.Tests/FolderSyncPlannerTests.cs
Normal file
@@ -0,0 +1,240 @@
|
||||
using Explorer.Application;
|
||||
using Explorer.Domain;
|
||||
using Explorer.Domain.Abstractions;
|
||||
|
||||
namespace Explorer.Application.Tests;
|
||||
|
||||
public class FolderSyncPlannerTests
|
||||
{
|
||||
private static readonly DateTimeOffset T0 = DateTimeOffset.Parse("2024-06-01T12:00:00Z");
|
||||
|
||||
[Fact]
|
||||
public void Copy_update_adds_and_overwrites_but_does_not_delete()
|
||||
{
|
||||
var fs = Tree()
|
||||
.Dir(@"C:\src")
|
||||
.File(@"C:\src\new.txt", 10, T0)
|
||||
.File(@"C:\src\same.txt", 20, T0)
|
||||
.File(@"C:\src\changed.txt", 30, T0)
|
||||
.Dir(@"C:\src\empty")
|
||||
.Dir(@"C:\dst")
|
||||
.File(@"C:\dst\same.txt", 20, T0)
|
||||
.File(@"C:\dst\changed.txt", 11, T0.AddSeconds(-10))
|
||||
.File(@"C:\dst\orphan.txt", 4, T0);
|
||||
|
||||
var plan = Build(fs, SyncMode.CopyUpdate);
|
||||
Assert.True(plan.CanEnqueue);
|
||||
Assert.DoesNotContain(plan.Operations, o => o.Op == TransferOp.Delete);
|
||||
Assert.Contains(plan.Operations, o => o.Op == TransferOp.Copy && o.SourcePath == @"C:\src\new.txt");
|
||||
Assert.Contains(plan.Operations, o => o.Op == TransferOp.Copy && o.SourcePath == @"C:\src\changed.txt");
|
||||
Assert.Contains(plan.SyncPreview, r => r.RelativePath == "same.txt" && r.Action == "Skip");
|
||||
Assert.Contains(plan.SyncPreview, r => r.RelativePath == "empty" && r.Action == "Copy");
|
||||
Assert.DoesNotContain(plan.SyncPreview, r => r.RelativePath == "orphan.txt");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dest_newer_is_skipped_with_a_warning()
|
||||
{
|
||||
var fs = Tree()
|
||||
.Dir(@"C:\src")
|
||||
.File(@"C:\src\photo.jpg", 100, T0)
|
||||
.Dir(@"C:\dst")
|
||||
.File(@"C:\dst\photo.jpg", 80, T0.AddSeconds(5));
|
||||
|
||||
var plan = Build(fs, SyncMode.CopyUpdate);
|
||||
Assert.False(plan.CanEnqueue);
|
||||
Assert.Empty(plan.Operations);
|
||||
Assert.Contains(plan.Issues, i => i.Severity == PlanIssueSeverity.Warning && i.Message.Contains("newer"));
|
||||
Assert.Contains(plan.SyncPreview, r => r.Action == "Skip" && r.Detail == "Destination is newer.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mirror_lists_destination_only_deletes()
|
||||
{
|
||||
var fs = Tree()
|
||||
.Dir(@"C:\src")
|
||||
.File(@"C:\src\keep.txt", 8, T0)
|
||||
.Dir(@"C:\dst")
|
||||
.File(@"C:\dst\keep.txt", 8, T0)
|
||||
.File(@"C:\dst\gone.txt", 3, T0)
|
||||
.Dir(@"C:\dst\old");
|
||||
|
||||
var plan = Build(fs, SyncMode.Mirror);
|
||||
Assert.True(plan.CanEnqueue);
|
||||
Assert.Contains(plan.Operations, o => o.Op == TransferOp.Delete && o.SourcePath == @"C:\dst\gone.txt");
|
||||
Assert.Contains(plan.Operations, o => o.Op == TransferOp.Delete && o.SourcePath == @"C:\dst\old");
|
||||
Assert.Contains(plan.SyncPreview, r => r.RelativePath == "gone.txt" && r.Action == "Delete");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Nested_or_same_folders_are_errors()
|
||||
{
|
||||
var fs = Tree().Dir(@"C:\src").Dir(@"C:\src\nested");
|
||||
var planner = new FolderSyncPlanner();
|
||||
var same = planner.Build(@"C:\src", @"C:\src", SyncMode.CopyUpdate, "", fs, _ => true);
|
||||
Assert.False(same.CanEnqueue);
|
||||
Assert.Contains(same.Issues, i => i.Message.Contains("different", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
var nested = planner.Build(@"C:\src", @"C:\src\nested", SyncMode.CopyUpdate, "", fs, _ => true);
|
||||
Assert.False(nested.CanEnqueue);
|
||||
Assert.Contains(nested.Issues, i => i.Message.Contains("contain", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Offline_destination_is_an_error_not_an_empty_mirror()
|
||||
{
|
||||
var fs = Tree()
|
||||
.Dir(@"C:\src")
|
||||
.File(@"C:\src\a.txt", 1, T0)
|
||||
.Dir(@"Z:\backup")
|
||||
.File(@"Z:\backup\a.txt", 1, T0)
|
||||
.File(@"Z:\backup\only-dest.txt", 2, T0);
|
||||
|
||||
var plan = new FolderSyncPlanner().Build(
|
||||
@"C:\src",
|
||||
@"Z:\backup",
|
||||
SyncMode.Mirror,
|
||||
"",
|
||||
fs,
|
||||
path => path.StartsWith(@"C:\", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.False(plan.CanEnqueue);
|
||||
Assert.Empty(plan.Operations);
|
||||
Assert.Contains(plan.Issues, i => i.Message == "Destination is not available.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Copy_update_allows_a_missing_dest_folder_when_the_parent_is_reachable()
|
||||
{
|
||||
var fs = Tree()
|
||||
.Dir(@"C:\src")
|
||||
.File(@"C:\src\a.txt", 1, T0)
|
||||
.Dir(@"C:\dst");
|
||||
|
||||
var plan = new FolderSyncPlanner().Build(
|
||||
@"C:\src",
|
||||
@"C:\dst\new",
|
||||
SyncMode.CopyUpdate,
|
||||
"",
|
||||
fs,
|
||||
path => !path.Equals(@"C:\dst\new", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.True(plan.CanEnqueue);
|
||||
Assert.Contains(plan.Operations, o => o.Op == TransferOp.Copy && o.DestinationPath == @"C:\dst\new\a.txt");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Excludes_and_online_only_files_are_skipped()
|
||||
{
|
||||
var fs = Tree()
|
||||
.Dir(@"C:\src")
|
||||
.File(@"C:\src\keep.txt", 1, T0)
|
||||
.File(@"C:\src\skip.tmp", 1, T0)
|
||||
.File(@"C:\src\cloud.bin", 1, T0)
|
||||
.Dir(@"C:\dst");
|
||||
|
||||
var plan = new FolderSyncPlanner().Build(
|
||||
@"C:\src",
|
||||
@"C:\dst",
|
||||
SyncMode.CopyUpdate,
|
||||
"*.tmp",
|
||||
fs,
|
||||
_ => true,
|
||||
item => item.Name == "cloud.bin");
|
||||
Assert.Contains(plan.Operations, o => o.SourcePath.EndsWith("keep.txt", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.DoesNotContain(plan.Operations, o => o.SourcePath.EndsWith("skip.tmp", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.DoesNotContain(plan.Operations, o => o.SourcePath.EndsWith("cloud.bin", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.Contains(plan.Issues, i => i.Message.Contains("Online-only", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reparse_directories_are_not_walked_or_copied()
|
||||
{
|
||||
var fs = Tree()
|
||||
.Dir(@"C:\src")
|
||||
.Dir(@"C:\src\link", AttributeFlags.Directory | AttributeFlags.ReparsePoint)
|
||||
.File(@"C:\src\link\secret.txt", 9, T0)
|
||||
.Dir(@"C:\dst");
|
||||
|
||||
var plan = Build(fs, SyncMode.CopyUpdate);
|
||||
Assert.DoesNotContain(plan.Operations, o => o.SourcePath.Contains("secret", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.DoesNotContain(plan.Operations, o => o.SourcePath.EndsWith(@"\link", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Remap_replaces_the_volume_root_when_the_guid_is_unique()
|
||||
{
|
||||
var sources = new List<Source>
|
||||
{
|
||||
new()
|
||||
{
|
||||
StableKey = "usb",
|
||||
DisplayName = "Photos",
|
||||
VolumeGuid = @"{abc}",
|
||||
LastRootPath = @"F:\"
|
||||
}
|
||||
};
|
||||
Assert.Equal(@"F:\photos", FolderSyncPlanner.RemapToVolume(@"E:\photos", "{abc}", sources));
|
||||
Assert.Equal(@"E:\photos", FolderSyncPlanner.RemapToVolume(@"E:\photos", "{abc}", []));
|
||||
}
|
||||
|
||||
private static OperationPlan Build(IFileSystemEnumerator fs, SyncMode mode)
|
||||
=> new FolderSyncPlanner().Build(@"C:\src", @"C:\dst", mode, "", fs, _ => true);
|
||||
|
||||
private static TreeEnumerator Tree() => new();
|
||||
}
|
||||
|
||||
sealed class TreeEnumerator : IFileSystemEnumerator
|
||||
{
|
||||
private readonly Dictionary<string, FileSystemItem> _items = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<string, List<FileSystemItem>> _children = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public TreeEnumerator Dir(string path, int attributes = AttributeFlags.Directory)
|
||||
{
|
||||
Add(new FileSystemItem
|
||||
{
|
||||
FullPath = path,
|
||||
Name = PathRules.GetFileName(path),
|
||||
IsDirectory = true,
|
||||
Attributes = attributes
|
||||
});
|
||||
return this;
|
||||
}
|
||||
|
||||
public TreeEnumerator File(string path, long size, DateTimeOffset modified)
|
||||
{
|
||||
Add(new FileSystemItem
|
||||
{
|
||||
FullPath = path,
|
||||
Name = PathRules.GetFileName(path),
|
||||
SizeBytes = size,
|
||||
ModifiedUtc = modified
|
||||
});
|
||||
return this;
|
||||
}
|
||||
|
||||
public IEnumerable<FileSystemItem> EnumerateChildren(string directoryPath)
|
||||
=> EnumerateChildrenSafe(directoryPath, out _);
|
||||
|
||||
public FileSystemItem? GetItem(string path)
|
||||
=> _items.TryGetValue(Key(path), out var item) ? item : null;
|
||||
|
||||
public IReadOnlyList<FileSystemItem> EnumerateChildrenSafe(string directoryPath, out string? error)
|
||||
{
|
||||
error = null;
|
||||
return _children.TryGetValue(Key(directoryPath), out var list) ? list : [];
|
||||
}
|
||||
|
||||
private void Add(FileSystemItem item)
|
||||
{
|
||||
_items[Key(item.FullPath)] = item;
|
||||
var parent = PathRules.Parent(item.FullPath);
|
||||
if (!_children.TryGetValue(Key(parent), out var list))
|
||||
{
|
||||
list = [];
|
||||
_children[Key(parent)] = list;
|
||||
}
|
||||
|
||||
list.Add(item);
|
||||
}
|
||||
|
||||
private static string Key(string path) => PathRules.FromExtended(path).TrimEnd('\\');
|
||||
}
|
||||
98
tests/Explorer.Application.Tests/GitPorcelainParserTests.cs
Normal file
98
tests/Explorer.Application.Tests/GitPorcelainParserTests.cs
Normal file
@@ -0,0 +1,98 @@
|
||||
using Explorer.Application;
|
||||
using Explorer.Domain;
|
||||
|
||||
namespace Explorer.Application.Tests;
|
||||
|
||||
public class GitPorcelainParserTests
|
||||
{
|
||||
[Fact]
|
||||
public void Parses_branch_counts_and_ahead_behind()
|
||||
{
|
||||
var status = GitPorcelainParser.Parse("""
|
||||
# branch.oid abcdef1234567890
|
||||
# branch.head main
|
||||
# branch.upstream origin/main
|
||||
# branch.ab +2 -1
|
||||
1 .M N... 100644 100644 100644 a a src/App.cs
|
||||
1 M. N... 100644 100644 100644 b b README.md
|
||||
2 R. N... 100644 100644 100644 c c R100 old.txt new.txt
|
||||
? bin/out.dll
|
||||
? notes.md
|
||||
! ignore.me
|
||||
""", @"C:\src");
|
||||
Assert.NotNull(status);
|
||||
Assert.Equal("main", status.Branch);
|
||||
Assert.Equal(3, status.ModifiedCount);
|
||||
Assert.Equal(2, status.UntrackedCount);
|
||||
Assert.Equal(2, status.Ahead);
|
||||
Assert.Equal(1, status.Behind);
|
||||
Assert.False(status.WorkingTreeClean);
|
||||
Assert.Equal("main · 3 modified · 2 untracked · 2 ahead · 1 behind", status.Badge);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Clean_tree_uses_detached_oid_prefix()
|
||||
{
|
||||
var status = GitPorcelainParser.Parse("""
|
||||
# branch.oid 0123456789abcdef
|
||||
# branch.head (detached)
|
||||
""", @"F:\repo");
|
||||
Assert.NotNull(status);
|
||||
Assert.Equal("0123456", status.Branch);
|
||||
Assert.True(status.WorkingTreeClean);
|
||||
Assert.Equal("0123456 · clean", status.Badge);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Empty_output_is_null()
|
||||
=> Assert.Null(GitPorcelainParser.Parse("", @"C:\src"));
|
||||
}
|
||||
|
||||
public class GitLocatorTests
|
||||
{
|
||||
[Fact]
|
||||
public void Prefers_the_configured_path_when_it_exists()
|
||||
{
|
||||
var path = @"C:\Tools\git.exe";
|
||||
Assert.Equal(path, GitLocator.Find(path, fileExists: p => p == path, pathVariable: ""));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Finds_git_on_PATH()
|
||||
{
|
||||
var found = GitLocator.Find(
|
||||
null,
|
||||
fileExists: p => p.Equals(@"D:\bin\git.exe", StringComparison.OrdinalIgnoreCase),
|
||||
pathVariable: @"C:\Windows;D:\bin");
|
||||
Assert.Equal(@"D:\bin\git.exe", found);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Returns_null_when_git_is_missing()
|
||||
=> Assert.Null(GitLocator.Find(null, fileExists: _ => false, pathVariable: @"C:\none"));
|
||||
}
|
||||
|
||||
public class GitRepoDetectorTests
|
||||
{
|
||||
[Fact]
|
||||
public void Finds_the_repo_root_from_a_nested_path()
|
||||
{
|
||||
var dirs = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { @"C:\src", @"C:\src\.git", @"C:\src\app" };
|
||||
var files = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
Assert.Equal(@"C:\src", GitRepoDetector.FindRoot(@"C:\src\app\Main.cs", dirs.Contains, files.Contains));
|
||||
Assert.True(GitRepoDetector.IsRepoRoot(@"C:\src", dirs.Contains, files.Contains));
|
||||
Assert.False(GitRepoDetector.IsRepoRoot(@"C:\src\app", dirs.Contains, files.Contains));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Treats_a_gitfile_as_a_worktree_root()
|
||||
{
|
||||
var dirs = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { @"C:\work" };
|
||||
var files = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { @"C:\work\.git" };
|
||||
Assert.Equal(@"C:\work", GitRepoDetector.FindRoot(@"C:\work", dirs.Contains, files.Contains));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Virtual_roots_are_not_repos()
|
||||
=> Assert.Null(GitRepoDetector.FindRoot(LocationRoots.ThisPc, _ => true, _ => true));
|
||||
}
|
||||
69
tests/Explorer.Application.Tests/MarkdownParserTests.cs
Normal file
69
tests/Explorer.Application.Tests/MarkdownParserTests.cs
Normal file
@@ -0,0 +1,69 @@
|
||||
using Explorer.Application;
|
||||
|
||||
namespace Explorer.Application.Tests;
|
||||
|
||||
public class MarkdownParserTests
|
||||
{
|
||||
[Fact]
|
||||
public void Parses_headings_lists_and_tables()
|
||||
{
|
||||
var doc = MarkdownParser.Parse("""
|
||||
# Title
|
||||
|
||||
Intro paragraph.
|
||||
|
||||
## Locations
|
||||
|
||||
- One
|
||||
- Two
|
||||
|
||||
| A | B |
|
||||
| --- | --- |
|
||||
| 1 | 2 |
|
||||
""");
|
||||
Assert.Contains(doc.Blocks, b => b is MarkdownHeading h && h.Level == 1 && h.Text == "Title");
|
||||
Assert.Equal("Locations", Assert.Single(doc.Headings).Text);
|
||||
var list = Assert.IsType<MarkdownList>(doc.Blocks.First(b => b is MarkdownList));
|
||||
Assert.Equal(["One", "Two"], list.Items);
|
||||
var table = Assert.IsType<MarkdownTable>(doc.Blocks.First(b => b is MarkdownTable));
|
||||
Assert.Equal(["A", "B"], table.Headers);
|
||||
Assert.Equal(["1", "2"], Assert.Single(table.Rows));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parses_fenced_code()
|
||||
{
|
||||
var doc = MarkdownParser.Parse("""
|
||||
```powershell
|
||||
dotnet build
|
||||
```
|
||||
""");
|
||||
var code = Assert.IsType<MarkdownCode>(Assert.Single(doc.Blocks));
|
||||
Assert.Equal("powershell", code.Language);
|
||||
Assert.Equal("dotnet build", code.Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void User_guide_in_the_repo_parses()
|
||||
{
|
||||
var dir = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
string? path = null;
|
||||
while (dir is not null)
|
||||
{
|
||||
var candidate = Path.Combine(dir.FullName, "docs", "Documentation.md");
|
||||
if (File.Exists(candidate))
|
||||
{
|
||||
path = candidate;
|
||||
break;
|
||||
}
|
||||
|
||||
dir = dir.Parent;
|
||||
}
|
||||
|
||||
Assert.NotNull(path);
|
||||
var doc = MarkdownParser.Parse(File.ReadAllText(path));
|
||||
Assert.Contains(doc.Headings, h => h.Text == "Locations");
|
||||
Assert.Contains(doc.Headings, h => h.Text == "File Operations Queue");
|
||||
Assert.Contains(doc.Blocks, b => b is MarkdownTable);
|
||||
}
|
||||
}
|
||||
187
tests/Explorer.Application.Tests/RenamePlannerTests.cs
Normal file
187
tests/Explorer.Application.Tests/RenamePlannerTests.cs
Normal file
@@ -0,0 +1,187 @@
|
||||
using Explorer.Application;
|
||||
using Explorer.Domain;
|
||||
|
||||
namespace Explorer.Application.Tests;
|
||||
|
||||
public class RenamePlannerTests
|
||||
{
|
||||
private static RenameSubject File(string path)
|
||||
=> new(path, PathRules.GetFileName(path), false);
|
||||
|
||||
[Fact]
|
||||
public void Search_replace_prefix_suffix_and_counter()
|
||||
{
|
||||
var plan = new RenamePlanner().Build(
|
||||
[File(@"C:\a\Vacation.jpg"), File(@"C:\a\Vacation (2).jpg")],
|
||||
new RenameRuleSet
|
||||
{
|
||||
Search = "Vacation",
|
||||
Replace = "Trip",
|
||||
Prefix = "2024_",
|
||||
Suffix = "_ok",
|
||||
UseCounter = true,
|
||||
CounterStart = 1,
|
||||
CounterPadding = 2
|
||||
});
|
||||
Assert.True(plan.CanEnqueue);
|
||||
Assert.Equal("2024_Trip_ok01.jpg", plan.Preview[0].NewName);
|
||||
Assert.Equal("2024_Trip (2)_ok02.jpg", plan.Preview[1].NewName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Regex_replace_and_case_and_extension()
|
||||
{
|
||||
var plan = new RenamePlanner().Build(
|
||||
[File(@"C:\a\IMG_001.JPG")],
|
||||
new RenameRuleSet
|
||||
{
|
||||
Search = @"IMG_(\d+)",
|
||||
Replace = "Photo_$1",
|
||||
UseRegex = true,
|
||||
CaseMode = RenameCaseMode.Lower,
|
||||
ChangeExtension = true,
|
||||
NewExtension = "jpg"
|
||||
});
|
||||
Assert.Equal("photo_001.jpg", plan.Preview[0].NewName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Counter_placeholder_does_not_need_hydration()
|
||||
{
|
||||
var plan = new RenamePlanner().Build(
|
||||
[File(@"C:\a\clip.mp4")],
|
||||
new RenameRuleSet { Prefix = "Clip_{Counter}_", UseCounter = true, CounterPadding = 3 });
|
||||
Assert.Equal("Clip_001_clip.mp4", plan.Preview[0].NewName);
|
||||
Assert.Contains("{Width}", new RenamePlanner().Build(
|
||||
[File(@"C:\a\clip.mp4")],
|
||||
new RenameRuleSet { Prefix = "{Width}_" }).Preview[0].NewName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Invalid_windows_name_is_an_error()
|
||||
{
|
||||
var plan = new RenamePlanner().Build(
|
||||
[File(@"C:\a\notes.txt")],
|
||||
new RenameRuleSet { Search = "notes", Replace = "CON" });
|
||||
Assert.False(plan.CanEnqueue);
|
||||
Assert.Contains(plan.Issues, i => i.Severity == PlanIssueSeverity.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Collision_when_two_files_map_to_the_same_name()
|
||||
{
|
||||
var plan = new RenamePlanner().Build(
|
||||
[File(@"C:\a\alpha.txt"), File(@"C:\a\beta.txt")],
|
||||
new RenameRuleSet { UseRegex = true, Search = ".+", Replace = "same" });
|
||||
Assert.False(plan.CanEnqueue);
|
||||
Assert.Contains(plan.Issues, i => i.Message.Contains("same name", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Existing_path_outside_the_batch_is_a_collision()
|
||||
{
|
||||
var plan = new RenamePlanner().Build(
|
||||
[File(@"C:\a\one.txt")],
|
||||
new RenameRuleSet { Search = "one", Replace = "held" },
|
||||
path => path.Equals(@"C:\a\held.txt", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.False(plan.CanEnqueue);
|
||||
Assert.Contains(plan.Issues, i => i.Message.Contains("already exists", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Collision_with_an_unchanged_file_in_the_batch()
|
||||
{
|
||||
var plan = new RenamePlanner().Build(
|
||||
[File(@"C:\a\one.txt"), File(@"C:\a\held.txt")],
|
||||
new RenameRuleSet { Search = "one", Replace = "held" });
|
||||
Assert.False(plan.CanEnqueue);
|
||||
Assert.Contains(plan.Issues, i => i.Message.Contains("already exists", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Chain_is_ordered_so_the_destination_moves_first()
|
||||
{
|
||||
var issues = new List<PlanIssue>();
|
||||
var ordered = RenamePlanner.Order(
|
||||
[
|
||||
(@"C:\a\a.txt", @"C:\a\b.txt", "b.txt"),
|
||||
(@"C:\a\b.txt", @"C:\a\c.txt", "c.txt")
|
||||
],
|
||||
issues);
|
||||
Assert.Empty(issues);
|
||||
Assert.Equal(@"C:\a\b.txt", ordered[0].Source);
|
||||
Assert.Equal(@"C:\a\a.txt", ordered[1].Source);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Cycle_is_an_error()
|
||||
{
|
||||
var issues = new List<PlanIssue>();
|
||||
RenamePlanner.Order(
|
||||
[
|
||||
(@"C:\a\x.txt", @"C:\a\y.txt", "y.txt"),
|
||||
(@"C:\a\y.txt", @"C:\a\x.txt", "x.txt")
|
||||
],
|
||||
issues);
|
||||
Assert.Contains(issues, i => i.Message.Contains("cycle", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Undo_restores_names_when_destination_still_matches()
|
||||
{
|
||||
var batch = new RenameBatch
|
||||
{
|
||||
Id = 1,
|
||||
CreatedUtc = DateTimeOffset.UtcNow,
|
||||
Items =
|
||||
[
|
||||
new RenameBatchItem(@"C:\a\old.txt", @"C:\a\new.txt", 0),
|
||||
new RenameBatchItem(@"C:\a\one.txt", @"C:\a\two.txt", 1)
|
||||
]
|
||||
};
|
||||
var exists = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { @"C:\a\new.txt", @"C:\a\two.txt" };
|
||||
var plan = new RenamePlanner().BuildUndo(batch, exists.Contains);
|
||||
Assert.True(plan.CanEnqueue);
|
||||
Assert.Equal(@"C:\a\two.txt", plan.Operations[0].SourcePath);
|
||||
Assert.Equal(@"C:\a\old.txt", plan.Operations[1].DestinationPath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Undo_refuses_when_rename_has_not_finished()
|
||||
{
|
||||
var batch = new RenameBatch
|
||||
{
|
||||
Id = 1,
|
||||
CreatedUtc = DateTimeOffset.UtcNow,
|
||||
Items = [new RenameBatchItem(@"C:\a\old.txt", @"C:\a\new.txt", 0)]
|
||||
};
|
||||
var exists = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { @"C:\a\old.txt" };
|
||||
var plan = new RenamePlanner().BuildUndo(batch, exists.Contains);
|
||||
Assert.True(plan.HasErrors);
|
||||
Assert.Contains(plan.Issues, i => i.Message.Contains("not finished", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Undo_fails_when_original_name_is_taken()
|
||||
{
|
||||
var batch = new RenameBatch
|
||||
{
|
||||
Id = 1,
|
||||
CreatedUtc = DateTimeOffset.UtcNow,
|
||||
Items = [new RenameBatchItem(@"C:\a\old.txt", @"C:\a\new.txt", 0)]
|
||||
};
|
||||
var exists = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { @"C:\a\new.txt", @"C:\a\old.txt" };
|
||||
var plan = new RenamePlanner().BuildUndo(batch, exists.Contains);
|
||||
Assert.True(plan.HasErrors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Invalid_regex_is_an_error()
|
||||
{
|
||||
var plan = new RenamePlanner().Build(
|
||||
[File(@"C:\a\a.txt")],
|
||||
new RenameRuleSet { UseRegex = true, Search = "(" });
|
||||
Assert.False(plan.CanEnqueue);
|
||||
Assert.Contains(plan.Issues, i => i.Message.Contains("regular expression", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
}
|
||||
166
tests/Explorer.Application.Tests/ReorganizePlannerTests.cs
Normal file
166
tests/Explorer.Application.Tests/ReorganizePlannerTests.cs
Normal file
@@ -0,0 +1,166 @@
|
||||
using Explorer.Application;
|
||||
using Explorer.Domain;
|
||||
using Explorer.Domain.Abstractions;
|
||||
|
||||
namespace Explorer.Application.Tests;
|
||||
|
||||
public class ReorganizePlannerTests
|
||||
{
|
||||
private static readonly DateTimeOffset T0 = DateTimeOffset.Parse("2024-06-01T12:00:00Z");
|
||||
|
||||
[Fact]
|
||||
public void Proposes_moves_and_leaves_unknown_and_build_output()
|
||||
{
|
||||
var fs = Tree()
|
||||
.Dir(@"C:\Downloads")
|
||||
.File(@"C:\Downloads\setup.exe", 10, T0)
|
||||
.File(@"C:\Downloads\photo.jpg", 20, T0)
|
||||
.File(@"C:\Downloads\clip.mkv", 30, T0)
|
||||
.File(@"C:\Downloads\notes.pdf", 8, T0)
|
||||
.File(@"C:\Downloads\pack.zip", 4, T0)
|
||||
.File(@"C:\Downloads\weird.bin", 1, T0)
|
||||
.Dir(@"C:\Downloads\node_modules")
|
||||
.File(@"C:\Downloads\node_modules\pkg.js", 1, T0)
|
||||
.Dir(@"C:\Software")
|
||||
.Dir(@"C:\Pictures")
|
||||
.Dir(@"C:\Videos")
|
||||
.Dir(@"C:\Docs")
|
||||
.Dir(@"C:\Archive");
|
||||
|
||||
var plan = Build(fs);
|
||||
Assert.True(plan.CanEnqueue);
|
||||
Assert.Contains(plan.Operations, o => o.Op == TransferOp.Move && o.SourcePath.EndsWith("setup.exe") && o.DestinationPath == @"C:\Software\setup.exe");
|
||||
Assert.Contains(plan.Operations, o => o.DestinationPath == @"C:\Pictures\photo.jpg");
|
||||
Assert.Contains(plan.Operations, o => o.DestinationPath == @"C:\Videos\clip.mkv");
|
||||
Assert.Contains(plan.Operations, o => o.DestinationPath == @"C:\Docs\notes.pdf");
|
||||
Assert.Contains(plan.Operations, o => o.DestinationPath == @"C:\Archive\pack.zip");
|
||||
Assert.DoesNotContain(plan.Operations, o => o.SourcePath.Contains("weird.bin", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.DoesNotContain(plan.Operations, o => o.SourcePath.Contains("node_modules", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.Contains(plan.OrganizePreview, r => r.Name == "weird.bin" && r.Action == "Skip");
|
||||
Assert.Contains(plan.OrganizePreview, r => r.Name == "node_modules" && r.Action == "Skip");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Git_repo_moves_as_a_unit_to_development()
|
||||
{
|
||||
var fs = Tree()
|
||||
.Dir(@"C:\Downloads")
|
||||
.Dir(@"C:\Downloads\Explorer")
|
||||
.File(@"C:\Downloads\Explorer\README.md", 1, T0)
|
||||
.Dir(@"C:\Dev");
|
||||
|
||||
var plan = Build(fs, dest => dest.Development = @"C:\Dev", isRepoRoot: path => path.EndsWith(@"\Explorer", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.Contains(plan.Operations, o => o.SourcePath == @"C:\Downloads\Explorer" && o.DestinationPath == @"C:\Dev\Explorer");
|
||||
Assert.DoesNotContain(plan.Operations, o => o.SourcePath.Contains("README", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Missing_destination_skips_that_category()
|
||||
{
|
||||
var fs = Tree()
|
||||
.Dir(@"C:\Downloads")
|
||||
.File(@"C:\Downloads\setup.exe", 10, T0)
|
||||
.File(@"C:\Downloads\photo.jpg", 20, T0)
|
||||
.Dir(@"C:\Pictures");
|
||||
|
||||
var plan = Build(fs, dest => dest.Installers = "");
|
||||
Assert.Contains(plan.Operations, o => o.DestinationPath == @"C:\Pictures\photo.jpg");
|
||||
Assert.DoesNotContain(plan.Operations, o => o.SourcePath.EndsWith("setup.exe"));
|
||||
Assert.Contains(plan.OrganizePreview, r => r.Name == "setup.exe" && r.Action == "Skip");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Old_installer_is_a_warning_still_proposed()
|
||||
{
|
||||
var old = T0.AddYears(-2);
|
||||
var fs = Tree()
|
||||
.Dir(@"C:\Downloads")
|
||||
.File(@"C:\Downloads\old.msi", 10, old)
|
||||
.Dir(@"C:\Software");
|
||||
|
||||
var plan = Build(fs, now: T0);
|
||||
Assert.True(plan.CanEnqueue);
|
||||
Assert.Contains(plan.Operations, o => o.SourcePath.EndsWith("old.msi"));
|
||||
Assert.Contains(plan.Issues, i => i.Severity == PlanIssueSeverity.Warning && i.Message.Contains("Old", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.Contains(plan.OrganizePreview, r => r.Name == "old.msi" && r.Detail == "Older than 1 year.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Offline_destination_is_an_error_and_does_not_enqueue()
|
||||
{
|
||||
var fs = Tree()
|
||||
.Dir(@"C:\Downloads")
|
||||
.File(@"C:\Downloads\photo.jpg", 20, T0)
|
||||
.Dir(@"Z:\Pictures");
|
||||
|
||||
var plan = new ReorganizePlanner().Build(
|
||||
@"C:\Downloads",
|
||||
Map(d => d.Pictures = @"Z:\Pictures"),
|
||||
fs,
|
||||
path => path.StartsWith(@"C:\", StringComparison.OrdinalIgnoreCase),
|
||||
_ => false);
|
||||
Assert.False(plan.CanEnqueue);
|
||||
Assert.Empty(plan.Operations);
|
||||
Assert.Contains(plan.Issues, i => i.Message == "Destination is not available.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Destination_inside_the_source_is_allowed()
|
||||
{
|
||||
var fs = Tree()
|
||||
.Dir(@"C:\Downloads")
|
||||
.File(@"C:\Downloads\setup.exe", 10, T0)
|
||||
.Dir(@"C:\Downloads\Software");
|
||||
|
||||
var plan = Build(fs, dest => dest.Installers = @"C:\Downloads\Software");
|
||||
Assert.True(plan.CanEnqueue);
|
||||
Assert.Contains(plan.Operations, o => o.DestinationPath == @"C:\Downloads\Software\setup.exe");
|
||||
Assert.DoesNotContain(plan.Operations, o => o.SourcePath == @"C:\Downloads\Software");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Planner_does_not_move_files_itself()
|
||||
{
|
||||
var fs = Tree()
|
||||
.Dir(@"C:\Downloads")
|
||||
.File(@"C:\Downloads\photo.jpg", 20, T0)
|
||||
.Dir(@"C:\Pictures");
|
||||
|
||||
var plan = Build(fs);
|
||||
Assert.NotEmpty(plan.Operations);
|
||||
Assert.NotNull(fs.GetItem(@"C:\Downloads\photo.jpg"));
|
||||
Assert.Null(fs.GetItem(@"C:\Pictures\photo.jpg"));
|
||||
}
|
||||
|
||||
private static OperationPlan Build(
|
||||
IFileSystemEnumerator fs,
|
||||
Action<OrganizeDestinations>? configure = null,
|
||||
Func<string, bool>? isRepoRoot = null,
|
||||
DateTimeOffset? now = null)
|
||||
=> new ReorganizePlanner().Build(
|
||||
@"C:\Downloads",
|
||||
Map(configure),
|
||||
fs,
|
||||
_ => true,
|
||||
isRepoRoot ?? (_ => false),
|
||||
now: now ?? T0,
|
||||
pathExists: _ => false);
|
||||
|
||||
private static OrganizeDestinations Map(Action<OrganizeDestinations>? configure = null)
|
||||
{
|
||||
var dest = new OrganizeDestinations
|
||||
{
|
||||
Pictures = @"C:\Pictures",
|
||||
Videos = @"C:\Videos",
|
||||
Audio = @"C:\Music",
|
||||
Documents = @"C:\Docs",
|
||||
Installers = @"C:\Software",
|
||||
Archives = @"C:\Archive",
|
||||
Development = @"C:\Dev"
|
||||
};
|
||||
configure?.Invoke(dest);
|
||||
return dest;
|
||||
}
|
||||
|
||||
private static TreeEnumerator Tree() => new();
|
||||
}
|
||||
30
tests/Explorer.Application.Tests/SevenZipLocatorTests.cs
Normal file
30
tests/Explorer.Application.Tests/SevenZipLocatorTests.cs
Normal file
@@ -0,0 +1,30 @@
|
||||
using Explorer.Application;
|
||||
|
||||
namespace Explorer.Application.Tests;
|
||||
|
||||
public class SevenZipLocatorTests
|
||||
{
|
||||
[Fact]
|
||||
public void Prefers_the_configured_path_when_it_exists()
|
||||
{
|
||||
var path = @"C:\Tools\7z.exe";
|
||||
Assert.Equal(path, SevenZipLocator.Find(path, fileExists: p => p == path, pathVariable: ""));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Finds_7z_on_PATH_when_not_configured()
|
||||
{
|
||||
var found = SevenZipLocator.Find(
|
||||
null,
|
||||
fileExists: p => p.Equals(@"D:\bin\7z.exe", StringComparison.OrdinalIgnoreCase),
|
||||
pathVariable: @"C:\Windows;D:\bin");
|
||||
Assert.Equal(@"D:\bin\7z.exe", found);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Returns_null_when_7zip_is_missing()
|
||||
{
|
||||
Assert.Null(SevenZipLocator.Find(null, fileExists: _ => false, pathVariable: @"C:\none"));
|
||||
Assert.Contains("7-Zip", SevenZipLocator.MissingHint, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
@@ -113,7 +113,8 @@ public class SourceManagerTests
|
||||
var env = new FakeEnv(Path.GetDirectoryName(db)!);
|
||||
var mgr = new SourceManager(store, volumes, env, new SystemClock(), NullLogger<SourceManager>.Instance);
|
||||
await mgr.InitializeAsync();
|
||||
var source = (await store.Sources.GetAllAsync()).Single();
|
||||
var source = await mgr.EnsureForPathAsync(@"Z:\");
|
||||
Assert.NotNull(source);
|
||||
await store.Entries.UpsertAsync(new IndexEntry
|
||||
{
|
||||
SourceId = source.Id,
|
||||
@@ -185,6 +186,89 @@ public class SourceManagerTests
|
||||
Assert.Empty(await store.Sources.GetAllAsync());
|
||||
Assert.Empty(File.ReadAllLines(Path.Combine(dir, "recents.txt")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Forget_then_rediscover_same_volume_guid()
|
||||
{
|
||||
var db = Path.Combine(Path.GetTempPath(), "ew-app", Guid.NewGuid().ToString("N"), "index.db");
|
||||
await using var store = new SqliteIndexStore(db, NullLogger<SqliteIndexStore>.Instance);
|
||||
await store.OpenAsync();
|
||||
var guid = @"\\?\Volume{phase2}\";
|
||||
var volumes = new FakeVolumes
|
||||
{
|
||||
Online =
|
||||
[
|
||||
new VolumeFingerprint
|
||||
{
|
||||
Kind = SourceKind.Removable,
|
||||
VolumeGuid = guid,
|
||||
RootPath = @"E:\",
|
||||
DisplayName = "Stick",
|
||||
VolumeSerial = 9,
|
||||
CapacityBytes = 64
|
||||
}
|
||||
]
|
||||
};
|
||||
var env = new FakeEnv(Path.GetDirectoryName(db)!);
|
||||
var mgr = new SourceManager(store, volumes, env, new SystemClock(), NullLogger<SourceManager>.Instance);
|
||||
await mgr.InitializeAsync();
|
||||
Assert.Equal(guid, (await store.Sources.GetAllAsync()).Single().VolumeGuid);
|
||||
|
||||
volumes.Online = [];
|
||||
await mgr.RefreshOnlineStateAsync();
|
||||
Assert.True(await mgr.ForgetDisconnectedAsync(@"E:\"));
|
||||
Assert.Empty(await store.Sources.GetAllAsync());
|
||||
|
||||
volumes.Online =
|
||||
[
|
||||
new VolumeFingerprint
|
||||
{
|
||||
Kind = SourceKind.Removable,
|
||||
VolumeGuid = guid,
|
||||
RootPath = @"F:\",
|
||||
DisplayName = "Stick",
|
||||
VolumeSerial = 9,
|
||||
CapacityBytes = 64
|
||||
}
|
||||
];
|
||||
await mgr.RefreshOnlineStateAsync();
|
||||
var restored = Assert.Single(await store.Sources.GetAllAsync());
|
||||
Assert.Equal(guid, restored.VolumeGuid);
|
||||
Assert.Equal(@"F:\", restored.LastRootPath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Mapped_network_drive_is_not_auto_added_until_imported()
|
||||
{
|
||||
var db = Path.Combine(Path.GetTempPath(), "ew-app", Guid.NewGuid().ToString("N"), "index.db");
|
||||
await using var store = new SqliteIndexStore(db, NullLogger<SqliteIndexStore>.Instance);
|
||||
await store.OpenAsync();
|
||||
var volumes = new FakeVolumes
|
||||
{
|
||||
Online =
|
||||
[
|
||||
new VolumeFingerprint
|
||||
{
|
||||
Kind = SourceKind.Smb,
|
||||
RootPath = @"Z:\",
|
||||
DisplayName = "Z: (Network)",
|
||||
Filesystem = "SMB"
|
||||
}
|
||||
]
|
||||
};
|
||||
var env = new FakeEnv(Path.GetDirectoryName(db)!);
|
||||
var mgr = new SourceManager(store, volumes, env, new SystemClock(), NullLogger<SourceManager>.Instance);
|
||||
await mgr.InitializeAsync();
|
||||
Assert.Empty(await store.Sources.GetAllAsync());
|
||||
var untracked = Assert.Single(await mgr.ListUntrackedOnlineVolumesAsync());
|
||||
Assert.Equal(@"Z:\", untracked.RootPath);
|
||||
|
||||
var imported = await mgr.EnsureForPathAsync(@"Z:\");
|
||||
Assert.NotNull(imported);
|
||||
Assert.False(imported!.IsIndexed);
|
||||
Assert.Empty(await mgr.ListUntrackedOnlineVolumesAsync());
|
||||
Assert.Equal(imported.Id, (await store.Sources.GetAllAsync()).Single().Id);
|
||||
}
|
||||
}
|
||||
|
||||
file sealed class FakeVolumes : IVolumeService
|
||||
|
||||
170
tests/Explorer.Application.Tests/ThumbnailSchedulerTests.cs
Normal file
170
tests/Explorer.Application.Tests/ThumbnailSchedulerTests.cs
Normal file
@@ -0,0 +1,170 @@
|
||||
using Explorer.Application;
|
||||
|
||||
namespace Explorer.Application.Tests;
|
||||
|
||||
public class ThumbnailSchedulerTests
|
||||
{
|
||||
[Fact]
|
||||
public void Only_window_items_are_queued_not_the_whole_directory()
|
||||
{
|
||||
var scheduler = new ThumbnailScheduler();
|
||||
scheduler.Reset(1);
|
||||
scheduler.SetEnabled(true);
|
||||
var window = Enumerable.Range(0, 40)
|
||||
.Select(i => Job($@"C:\v{i}.jpg", rank: 0))
|
||||
.Concat(Enumerable.Range(0, 24).Select(i => Job($@"C:\p{i}.jpg", rank: 1)))
|
||||
.ToList();
|
||||
scheduler.SetWindow(1, window);
|
||||
|
||||
var taken = Drain(scheduler);
|
||||
Assert.Equal(64, taken.Count);
|
||||
Assert.DoesNotContain(taken, j => j.Key.Path.Contains(@"\rest", StringComparison.Ordinal));
|
||||
Assert.True(taken.Take(40).All(j => j.Rank == 0));
|
||||
Assert.Equal(0, scheduler.InFlight);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Visible_jobs_are_taken_before_prefetch()
|
||||
{
|
||||
var scheduler = new ThumbnailScheduler();
|
||||
scheduler.Reset(1);
|
||||
scheduler.SetEnabled(true);
|
||||
scheduler.SetWindow(1,
|
||||
[
|
||||
Job(@"C:\far.jpg", rank: 1),
|
||||
Job(@"C:\near.jpg", rank: 0)
|
||||
]);
|
||||
|
||||
Assert.True(scheduler.TryTake(out var first));
|
||||
Assert.Equal(@"C:\near.jpg", first.Key.Path);
|
||||
Assert.True(scheduler.TryTake(out var second));
|
||||
Assert.Equal(@"C:\far.jpg", second.Key.Path);
|
||||
scheduler.Complete(first, generated: true, failed: false);
|
||||
scheduler.Complete(second, generated: true, failed: false);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Scrolling_away_drops_obsolete_pending_work()
|
||||
{
|
||||
var scheduler = new ThumbnailScheduler();
|
||||
scheduler.Reset(1);
|
||||
scheduler.SetEnabled(true);
|
||||
scheduler.SetWindow(1, [Job(@"C:\old.jpg")]);
|
||||
scheduler.SetWindow(1, [Job(@"C:\new.jpg")]);
|
||||
|
||||
Assert.True(scheduler.TryTake(out var job));
|
||||
Assert.Equal(@"C:\new.jpg", job.Key.Path);
|
||||
Assert.False(scheduler.TryTake(out _));
|
||||
Assert.True(scheduler.Snapshot().Cancelled > 0);
|
||||
scheduler.Complete(job, generated: true, failed: false);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Navigation_generation_invalidates_previous_jobs()
|
||||
{
|
||||
var scheduler = new ThumbnailScheduler();
|
||||
scheduler.Reset(1);
|
||||
scheduler.SetEnabled(true);
|
||||
scheduler.SetWindow(1, [Job(@"C:\a.jpg")]);
|
||||
Assert.True(scheduler.TryTake(out var stale));
|
||||
scheduler.Reset(2);
|
||||
Assert.False(scheduler.IsCurrent(stale));
|
||||
Assert.False(scheduler.TryTake(out _));
|
||||
scheduler.SetWindow(2, [Job(@"C:\b.jpg", generation: 2)]);
|
||||
Assert.True(scheduler.TryTake(out var next));
|
||||
Assert.Equal(@"C:\b.jpg", next.Key.Path);
|
||||
scheduler.Complete(next, generated: true, failed: false);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Cache_hit_skips_regeneration()
|
||||
{
|
||||
var scheduler = new ThumbnailScheduler();
|
||||
scheduler.Reset(1);
|
||||
scheduler.SetEnabled(true);
|
||||
var key = ThumbnailKey.From(@"C:\hit.jpg", DateTimeOffset.UnixEpoch, 128);
|
||||
scheduler.MarkCached(key);
|
||||
scheduler.SetWindow(1, [new ThumbnailJob(key, 0, 1)]);
|
||||
Assert.False(scheduler.TryTake(out _));
|
||||
scheduler.RecordCacheHit();
|
||||
Assert.Equal(1, scheduler.Snapshot().CacheHits);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Window_is_capped_so_the_queue_cannot_hold_the_whole_directory()
|
||||
{
|
||||
var scheduler = new ThumbnailScheduler();
|
||||
scheduler.Reset(1);
|
||||
scheduler.SetEnabled(true);
|
||||
var huge = Enumerable.Range(0, 500).Select(i => Job($@"C:\{i}.jpg")).ToList();
|
||||
scheduler.SetWindow(1, huge);
|
||||
Assert.True(scheduler.Snapshot().Window <= ThumbnailScheduler.MaxPending);
|
||||
var taken = Drain(scheduler);
|
||||
Assert.Equal(ThumbnailScheduler.MaxPending, taken.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void In_flight_work_is_tracked_until_complete()
|
||||
{
|
||||
var scheduler = new ThumbnailScheduler();
|
||||
scheduler.Reset(1);
|
||||
scheduler.SetEnabled(true);
|
||||
scheduler.SetWindow(1, Enumerable.Range(0, 8).Select(i => Job($@"C:\{i}.jpg")).ToList());
|
||||
Assert.True(scheduler.TryTake(out var a));
|
||||
Assert.True(scheduler.TryTake(out var b));
|
||||
Assert.Equal(2, scheduler.InFlight);
|
||||
scheduler.Complete(a, generated: true, failed: false);
|
||||
Assert.Equal(1, scheduler.InFlight);
|
||||
scheduler.Complete(b, generated: true, failed: false);
|
||||
Assert.Equal(0, scheduler.InFlight);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Preview_disabled_cancels_window()
|
||||
{
|
||||
var scheduler = new ThumbnailScheduler();
|
||||
scheduler.Reset(1);
|
||||
scheduler.SetEnabled(true);
|
||||
scheduler.SetWindow(1, [Job(@"C:\x.jpg")]);
|
||||
scheduler.SetEnabled(false);
|
||||
Assert.False(scheduler.TryTake(out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Cache_key_changes_when_file_is_modified()
|
||||
{
|
||||
var first = ThumbnailKey.From(@"C:\a.jpg", DateTimeOffset.UnixEpoch, 128);
|
||||
var second = ThumbnailKey.From(@"C:\a.jpg", DateTimeOffset.UnixEpoch.AddHours(1), 128);
|
||||
Assert.NotEqual(first, second);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Lru_cache_evicts_oldest_when_full()
|
||||
{
|
||||
var cache = new LruCache<string, int>(2);
|
||||
cache.Set("a", 1);
|
||||
cache.Set("b", 2);
|
||||
cache.Set("c", 3);
|
||||
Assert.False(cache.TryGet("a", out _));
|
||||
Assert.True(cache.TryGet("b", out var b));
|
||||
Assert.Equal(2, b);
|
||||
cache.Set("d", 4);
|
||||
Assert.False(cache.TryGet("c", out _));
|
||||
Assert.True(cache.TryGet("b", out _));
|
||||
}
|
||||
|
||||
private static ThumbnailJob Job(string path, int rank = 0, int generation = 1)
|
||||
=> new(ThumbnailKey.From(path, DateTimeOffset.UnixEpoch, 128), rank, generation);
|
||||
|
||||
private static List<ThumbnailJob> Drain(ThumbnailScheduler scheduler)
|
||||
{
|
||||
var taken = new List<ThumbnailJob>();
|
||||
while (scheduler.TryTake(out var job))
|
||||
{
|
||||
taken.Add(job);
|
||||
scheduler.Complete(job, generated: true, failed: false);
|
||||
}
|
||||
|
||||
return taken;
|
||||
}
|
||||
}
|
||||
@@ -15,13 +15,32 @@ public class UiPreferencesStoreTests
|
||||
"group-network=true",
|
||||
"group-cloud=false",
|
||||
"index-archives=true",
|
||||
"auto-clear-queue=true"
|
||||
"auto-clear-queue=true",
|
||||
"seven-zip=C:\\Program Files\\7-Zip\\7z.exe"
|
||||
]);
|
||||
Assert.Equal("Light", prefs.Theme);
|
||||
Assert.True(prefs.GroupNetworkPlaces);
|
||||
Assert.False(prefs.GroupCloudPlaces);
|
||||
Assert.True(prefs.IndexArchiveContents);
|
||||
Assert.True(prefs.AutoClearQueueWhenDone);
|
||||
Assert.Equal(@"C:\Program Files\7-Zip\7z.exe", prefs.SevenZipPath);
|
||||
Assert.Null(prefs.GitPath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_reads_organize_destinations()
|
||||
{
|
||||
var prefs = UiPreferencesStore.Parse(["organize-installers=D:\\Software", "organize-pictures=D:\\Pictures"]);
|
||||
Assert.Equal(@"D:\Software", prefs.OrganizeInstallers);
|
||||
Assert.Equal(@"D:\Pictures", prefs.OrganizePictures);
|
||||
Assert.Null(prefs.OrganizeArchives);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_reads_git_path()
|
||||
{
|
||||
var prefs = UiPreferencesStore.Parse(["git=C:\\Program Files\\Git\\cmd\\git.exe"]);
|
||||
Assert.Equal(@"C:\Program Files\Git\cmd\git.exe", prefs.GitPath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -35,6 +54,8 @@ public class UiPreferencesStoreTests
|
||||
Assert.True(prefs.ShowHiddenFiles);
|
||||
Assert.False(prefs.ShowProtectedSystemLocations);
|
||||
Assert.False(prefs.AutoClearQueueWhenDone);
|
||||
Assert.Null(prefs.SevenZipPath);
|
||||
Assert.Null(prefs.GitPath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -105,6 +126,31 @@ public class LocationVisibilityTests
|
||||
Assert.Equal(SizeKnowledge.Unknown, LocationVisibility.ResolveSizeKnowledge(info, true, 0, true));
|
||||
Assert.Equal(SizeKnowledge.Partial, LocationVisibility.ResolveSizeKnowledge(info, true, 1000, true));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Recycle_bin_folder_is_never_shown()
|
||||
{
|
||||
var recycle = LocationClassifier.Classify(
|
||||
@"C:\$RECYCLE.BIN", "$RECYCLE.BIN",
|
||||
AttributeFlags.Directory | AttributeFlags.Hidden | AttributeFlags.System, true);
|
||||
Assert.False(LocationVisibility.ShouldShow(recycle, UiPreferences.Default));
|
||||
Assert.False(LocationVisibility.ShouldShow(recycle, UiPreferences.Default with
|
||||
{
|
||||
ShowHiddenFiles = true,
|
||||
ShowProtectedSystemLocations = true
|
||||
}));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Recycle_bin_summary_clamps_negative_query_values()
|
||||
{
|
||||
var summary = RecycleBinSummary.FromQuery(-12, -3);
|
||||
Assert.Equal(0, summary.ItemCount);
|
||||
Assert.Equal(0, summary.UsedBytes);
|
||||
var ok = RecycleBinSummary.FromQuery(4096, 3);
|
||||
Assert.Equal(3, ok.ItemCount);
|
||||
Assert.Equal(4096, ok.UsedBytes);
|
||||
}
|
||||
}
|
||||
|
||||
file sealed class PrefsEnv : IAppEnvironment
|
||||
|
||||
Reference in New Issue
Block a user