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(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.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.Instance); await sources.InitializeAsync(); var providers = provider is null ? Array.Empty() : new[] { provider }; var browse = new BrowseService( enumerator ?? new GuardedEnumerator(), volumes, store, sources, new StorageProviderRegistry(providers, NullLogger.Instance), new CloudPlaceStore(env), new UiPreferencesStore(env)); return (browse, store, env); } } file sealed class GuardedEnumerator : IFileSystemEnumerator { public int GetItemCalls { get; private set; } public IEnumerable EnumerateChildren(string directoryPath) => EnumerateChildrenSafe(directoryPath, out _); public FileSystemItem? GetItem(string path) { GetItemCalls++; throw new InvalidOperationException("GetItem must not run during folder listing."); } public IReadOnlyList 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 items) : IFileSystemEnumerator { public IEnumerable EnumerateChildren(string directoryPath) => EnumerateChildrenSafe(directoryPath, out _); public FileSystemItem? GetItem(string path) => throw new InvalidOperationException("GetItem must not run during folder listing."); public IReadOnlyList 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 GetPlaces() => []; public async Task> GetItemStatesAsync( IReadOnlyList 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 TryInvokeAsync(ProviderActionRequest request, CancellationToken cancellationToken = default) => Task.FromResult(new ProviderActionResult(ProviderActionStatus.Unsupported)); public Task TryGetQuotaAsync(string rootPath, CancellationToken cancellationToken = default) => Task.FromResult(null); } file sealed class RecordingProvider(string root) : IStorageProvider { public List 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 GetPlaces() => []; public Task> GetItemStatesAsync( IReadOnlyList paths, CancellationToken cancellationToken = default) { BatchSizes.Add(paths.Count); return Task.FromResult>( paths.Select(p => new ProviderItemState( "onedrive", p, Plugin.Abstractions.CloudAvailability.LocallyAvailable, 1, 1, false, false, null, null)).ToList()); } public Task TryInvokeAsync(ProviderActionRequest request, CancellationToken cancellationToken = default) => Task.FromResult(new ProviderActionResult(ProviderActionStatus.Unsupported)); public Task TryGetQuotaAsync(string rootPath, CancellationToken cancellationToken = default) => Task.FromResult(null); } file sealed class HydrationVolumes : IVolumeService { public List Online { get; set; } = []; public IReadOnlyList 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; } }