344 lines
14 KiB
C#
344 lines
14 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 BrowseServiceTests
|
|
{
|
|
[Fact]
|
|
public async Task This_pc_shows_indexed_root_aggregate_size()
|
|
{
|
|
var (browse, store) = await CreateAsync(@"C:\", "C: (930.6 GB)");
|
|
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,
|
|
AggregateSize = 1_073_741_824
|
|
};
|
|
await store.Entries.UpsertAsync(root);
|
|
|
|
var listing = await browse.ListThisPcAsync();
|
|
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]
|
|
public async Task Live_listing_overlays_folder_aggregate_from_index()
|
|
{
|
|
var (browse, store) = await CreateAsync(@"C:\", "C:");
|
|
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);
|
|
Assert.Equal(250_000_000, movies.FreeSpaceBytes);
|
|
var loose = Assert.Single(listing.Items, i => i.Name == "loose.txt");
|
|
Assert.Equal(10, loose.SizeBytes);
|
|
}
|
|
|
|
[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);
|
|
await store.OpenAsync();
|
|
var volumes = new BrowseVolumes
|
|
{
|
|
Online =
|
|
[
|
|
new VolumeFingerprint
|
|
{
|
|
Kind = SourceKind.NtfsLocal,
|
|
RootPath = root,
|
|
DisplayName = display,
|
|
VolumeSerial = 1,
|
|
CapacityBytes = 1_000_000_000,
|
|
FreeBytes = 250_000_000
|
|
}
|
|
]
|
|
};
|
|
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(
|
|
enumerator ?? new BrowseEnumerator(),
|
|
volumes,
|
|
store,
|
|
sources,
|
|
new StorageProviderRegistry([], NullLogger<StorageProviderRegistry>.Instance),
|
|
new CloudPlaceStore(env),
|
|
prefs,
|
|
recycle: recycle);
|
|
return (browse, store);
|
|
}
|
|
}
|
|
|
|
file sealed class BrowseEnumerator : 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, "Movies"), Name = "Movies", IsDirectory = true },
|
|
new FileSystemItem { FullPath = Path.Combine(directoryPath, "loose.txt"), Name = "loose.txt", SizeBytes = 10 }
|
|
];
|
|
}
|
|
}
|
|
|
|
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; } = [];
|
|
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 BrowseEnv : IAppEnvironment
|
|
{
|
|
public BrowseEnv(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; }
|
|
}
|