512 lines
20 KiB
C#
512 lines
20 KiB
C#
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));
|
|
Assert.Equal(1, BrowseHydration.FirstPublish);
|
|
Assert.True(BrowseHydration.FirstPublish < BrowseHydration.PublishBatch);
|
|
}
|
|
|
|
[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_publishes_first_names_before_enumeration_finishes()
|
|
{
|
|
var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
var (browse, store, _) = await CreateAsync(enumerator: new GatedEnumerator(gate));
|
|
try
|
|
{
|
|
await using var it = browse.ListProgressiveAsync(@"C:\").GetAsyncEnumerator();
|
|
var first = it.MoveNextAsync().AsTask();
|
|
var winner = await Task.WhenAny(first, Task.Delay(TimeSpan.FromSeconds(2))).ConfigureAwait(true);
|
|
Assert.Same(first, winner);
|
|
Assert.True(await first);
|
|
Assert.Contains(it.Current.Added, i => i.Name == "first");
|
|
Assert.False(it.Current.EnumerationComplete);
|
|
Assert.False(gate.Task.IsCompleted);
|
|
|
|
gate.SetResult();
|
|
var sawSecond = it.Current.Added.Any(i => i.Name == "second");
|
|
var complete = it.Current.EnumerationComplete;
|
|
while (!complete && await it.MoveNextAsync())
|
|
{
|
|
sawSecond |= it.Current.Added.Any(i => i.Name == "second");
|
|
complete = it.Current.EnumerationComplete;
|
|
}
|
|
|
|
Assert.True(sawSecond);
|
|
}
|
|
finally
|
|
{
|
|
await store.DisposeAsync();
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Progressive_listing_applies_index_sizes_after_names()
|
|
{
|
|
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
|
|
});
|
|
|
|
await using var it = browse.ListProgressiveAsync(@"C:\").GetAsyncEnumerator();
|
|
Assert.True(await it.MoveNextAsync());
|
|
Assert.NotEmpty(it.Current.Added);
|
|
Assert.All(it.Current.Added, item => Assert.Equal(0, (int)(item.Hydration & ItemHydrationFlags.Index)));
|
|
var movies = it.Current.Added.FirstOrDefault(i => i.Name == "Movies");
|
|
if (movies is not null)
|
|
{
|
|
Assert.Equal(0, movies.SizeBytes);
|
|
}
|
|
|
|
long? size = movies?.SizeBytes;
|
|
var complete = it.Current.HydrationComplete;
|
|
while (!complete && await it.MoveNextAsync())
|
|
{
|
|
movies ??= it.Current.Added.FirstOrDefault(i => i.Name == "Movies");
|
|
var updated = it.Current.Updated.FirstOrDefault(i => i.Name == "Movies");
|
|
if (updated is not null)
|
|
{
|
|
size = updated.SizeBytes;
|
|
}
|
|
|
|
complete = it.Current.HydrationComplete;
|
|
}
|
|
|
|
Assert.Equal(200, size);
|
|
}
|
|
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 GatedEnumerator(TaskCompletionSource gate) : 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
|
|
[
|
|
new FileSystemItem { FullPath = Path.Combine(directoryPath, "first"), Name = "first", IsDirectory = true },
|
|
new FileSystemItem { FullPath = Path.Combine(directoryPath, "second"), Name = "second", IsDirectory = true }
|
|
];
|
|
}
|
|
|
|
public IEnumerable<FileSystemItem> EnumerateChildrenStreaming(
|
|
string directoryPath,
|
|
FileEnumerationSink sink,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
yield return new FileSystemItem
|
|
{
|
|
FullPath = Path.Combine(directoryPath, "first"),
|
|
Name = "first",
|
|
IsDirectory = true
|
|
};
|
|
gate.Task.GetAwaiter().GetResult();
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
yield return new FileSystemItem
|
|
{
|
|
FullPath = Path.Combine(directoryPath, "second"),
|
|
Name = "second",
|
|
IsDirectory = true
|
|
};
|
|
}
|
|
}
|
|
|
|
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; }
|
|
}
|