Files

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