Add Explorer Workbench with hierarchical, off-UI Storage analysis.
Storage queries run in the background with cancellation and covering indexes so switching views no longer freezes the UI. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
7
tests/Directory.Build.props
Normal file
7
tests/Directory.Build.props
Normal file
@@ -0,0 +1,7 @@
|
||||
<Project>
|
||||
<Import Project="$([MSBuild]::GetPathOfFileAbove('Directory.Build.props', '$(MSBuildThisFileDirectory)../'))" />
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
<Compile Include="$(MSBuildThisFileDirectory)DisableParallelization.cs" Link="DisableParallelization.cs" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
3
tests/DisableParallelization.cs
Normal file
3
tests/DisableParallelization.cs
Normal file
@@ -0,0 +1,3 @@
|
||||
using Xunit;
|
||||
|
||||
[assembly: CollectionBehavior(DisableTestParallelization = true)]
|
||||
139
tests/Explorer.Analysis.Tests/AnalysisTests.cs
Normal file
139
tests/Explorer.Analysis.Tests/AnalysisTests.cs
Normal file
@@ -0,0 +1,139 @@
|
||||
using Explorer.Analysis;
|
||||
using Explorer.Application;
|
||||
using Explorer.Domain;
|
||||
using Explorer.Presentation.ViewModels;
|
||||
using Explorer.Storage.Sqlite;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace Explorer.Analysis.Tests;
|
||||
|
||||
public class AnalysisTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Largest_dirs_files_and_types()
|
||||
{
|
||||
var db = Path.Combine(Path.GetTempPath(), "ew-an", Guid.NewGuid().ToString("N"), "index.db");
|
||||
await using var store = new SqliteIndexStore(db, NullLogger<SqliteIndexStore>.Instance);
|
||||
await store.OpenAsync();
|
||||
var source = new Source { StableKey = "k", DisplayName = "Media", Kind = SourceKind.NtfsLocal, LastRootPath = @"C:\m", Status = SourceStatus.Online };
|
||||
source.Id = await store.Sources.UpsertAsync(source);
|
||||
var root = new IndexEntry { SourceId = source.Id, Name = "m", NameNorm = "m", IsDirectory = true, PathRel = "", LastSeenUtc = DateTimeOffset.UtcNow, AggregateSize = 300 };
|
||||
root.Id = await store.Entries.UpsertAsync(root);
|
||||
var movies = new IndexEntry { SourceId = source.Id, ParentId = root.Id, Name = "Movies", NameNorm = "movies", IsDirectory = true, PathRel = "Movies", LastSeenUtc = DateTimeOffset.UtcNow, AggregateSize = 200 };
|
||||
movies.Id = await store.Entries.UpsertAsync(movies);
|
||||
await store.Entries.UpsertAsync(new IndexEntry
|
||||
{
|
||||
SourceId = source.Id, ParentId = movies.Id, Name = "a.mkv", NameNorm = "a.mkv", Extension = "mkv",
|
||||
SizeBytes = 200, PathRel = @"Movies\a.mkv", LastSeenUtc = DateTimeOffset.UtcNow
|
||||
});
|
||||
await store.Entries.UpsertAsync(new IndexEntry
|
||||
{
|
||||
SourceId = source.Id, ParentId = root.Id, Name = "b.txt", NameNorm = "b.txt", Extension = "txt",
|
||||
SizeBytes = 100, PathRel = "b.txt", LastSeenUtc = DateTimeOffset.UtcNow
|
||||
});
|
||||
|
||||
var analysis = new AnalysisService(store);
|
||||
var dirs = await analysis.LargestDirectoriesAsync(source.Id, root.Id);
|
||||
Assert.Equal("Movies", dirs[0].Name);
|
||||
var files = await analysis.LargestFilesAsync(source.Id, null);
|
||||
Assert.Equal("a.mkv", files[0].Name);
|
||||
var types = await analysis.UsageByExtensionAsync(source.Id, null);
|
||||
Assert.Contains(types, t => t.Extension == "mkv" && t.TotalSize == 200);
|
||||
var drill = await analysis.DrilldownAsync(root.Id);
|
||||
Assert.Contains(drill, e => e.Name == "Movies");
|
||||
Assert.Equal("Movies", dirs[0].PathRel);
|
||||
var global = await analysis.LargestDirectoriesAsync(source.Id, null);
|
||||
Assert.Contains(global, d => d.PathRel == "Movies");
|
||||
Assert.DoesNotContain(global, d => d.ParentId is null);
|
||||
|
||||
var first = await analysis.GetIndexStampAsync();
|
||||
await store.Sources.UpdateIndexedAsync(source.Id, DateTimeOffset.UtcNow, source.ScanGeneration + 1);
|
||||
var second = await analysis.GetIndexStampAsync();
|
||||
Assert.NotEqual(first, second);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sibling_size_bars_scale_to_the_largest_child()
|
||||
{
|
||||
var siblings = new[]
|
||||
{
|
||||
new StorageNodeViewModel { Name = "A", FullPath = @"C:\A", PathRel = "A", SourceId = 1, Size = 80 },
|
||||
new StorageNodeViewModel { Name = "B", FullPath = @"C:\B", PathRel = "B", SourceId = 1, Size = 40 },
|
||||
new StorageNodeViewModel { Name = "C", FullPath = @"C:\C", PathRel = "C", SourceId = 1, Size = 0 }
|
||||
};
|
||||
|
||||
AnalysisViewModel.ApplySiblingFractions(siblings);
|
||||
|
||||
Assert.Equal(1, siblings[0].Fraction);
|
||||
Assert.Equal(0.5, siblings[1].Fraction);
|
||||
Assert.Equal(0, siblings[2].Fraction);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Hash_worker_skips_online_only_without_opening()
|
||||
{
|
||||
var db = Path.Combine(Path.GetTempPath(), "ew-hash", Guid.NewGuid().ToString("N"), "index.db");
|
||||
var rootPath = Path.Combine(Path.GetTempPath(), "ew-hash-fs", Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(rootPath);
|
||||
File.WriteAllBytes(Path.Combine(rootPath, "local.bin"), new byte[64]);
|
||||
var missingOnline = Path.Combine(rootPath, "online-only.bin");
|
||||
|
||||
await using var store = new SqliteIndexStore(db, NullLogger<SqliteIndexStore>.Instance);
|
||||
await store.OpenAsync();
|
||||
var source = new Source
|
||||
{
|
||||
StableKey = "h",
|
||||
DisplayName = "H",
|
||||
Kind = SourceKind.NtfsLocal,
|
||||
LastRootPath = rootPath,
|
||||
Status = SourceStatus.Online
|
||||
};
|
||||
source.Id = await store.Sources.UpsertAsync(source);
|
||||
var root = new IndexEntry
|
||||
{
|
||||
SourceId = source.Id,
|
||||
Name = "H",
|
||||
NameNorm = "h",
|
||||
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 = "local.bin",
|
||||
NameNorm = "local.bin",
|
||||
SizeBytes = 64,
|
||||
PathRel = "local.bin",
|
||||
LastSeenUtc = DateTimeOffset.UtcNow
|
||||
});
|
||||
var online = new IndexEntry
|
||||
{
|
||||
SourceId = source.Id,
|
||||
ParentId = root.Id,
|
||||
Name = "online-only.bin",
|
||||
NameNorm = "online-only.bin",
|
||||
SizeBytes = 64,
|
||||
PathRel = "online-only.bin",
|
||||
LastSeenUtc = DateTimeOffset.UtcNow,
|
||||
Attributes = AttributeFlags.RecallOnDataAccess,
|
||||
CloudAvailability = CloudAvailability.OnlineOnly
|
||||
};
|
||||
online.Id = await store.Entries.UpsertAsync(online);
|
||||
|
||||
await store.Hashes.EnqueueSizeCollisionsAsync(source.Id);
|
||||
var worker = new DuplicateHashWorker(
|
||||
store,
|
||||
new HydrationGuard(new StorageProviderRegistry([], NullLogger<StorageProviderRegistry>.Instance)),
|
||||
NullLogger<DuplicateHashWorker>.Instance);
|
||||
await worker.ProcessPendingAsync(CancellationToken.None);
|
||||
|
||||
var skipped = await store.Entries.GetByPathAsync(source.Id, "online-only.bin");
|
||||
Assert.Equal(HashState.Skipped, skipped!.HashState);
|
||||
Assert.False(File.Exists(missingOnline));
|
||||
var local = await store.Entries.GetByPathAsync(source.Id, "local.bin");
|
||||
Assert.Equal(HashState.Partial, local!.HashState);
|
||||
}
|
||||
}
|
||||
19
tests/Explorer.Analysis.Tests/Explorer.Analysis.Tests.csproj
Normal file
19
tests/Explorer.Analysis.Tests/Explorer.Analysis.Tests.csproj
Normal file
@@ -0,0 +1,19 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Explorer.Domain\Explorer.Domain.csproj" />
|
||||
<ProjectReference Include="..\..\src\Explorer.Analysis\Explorer.Analysis.csproj" />
|
||||
<ProjectReference Include="..\..\src\Explorer.Storage.Sqlite\Explorer.Storage.Sqlite.csproj" />
|
||||
<ProjectReference Include="..\..\src\Explorer.Application\Explorer.Application.csproj" />
|
||||
<ProjectReference Include="..\..\src\Explorer.Presentation\Explorer.Presentation.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
145
tests/Explorer.Application.Tests/BrowseServiceTests.cs
Normal file
145
tests/Explorer.Application.Tests/BrowseServiceTests.cs
Normal file
@@ -0,0 +1,145 @@
|
||||
using Explorer.Application;
|
||||
using Explorer.Domain;
|
||||
using Explorer.Domain.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);
|
||||
Assert.Equal(1_073_741_824, item.SizeBytes);
|
||||
Assert.True(item.IsDirectory);
|
||||
}
|
||||
|
||||
[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);
|
||||
var loose = Assert.Single(listing.Items, i => i.Name == "loose.txt");
|
||||
Assert.Equal(10, loose.SizeBytes);
|
||||
}
|
||||
|
||||
private static async Task<(BrowseService Browse, SqliteIndexStore Store)> CreateAsync(string root, string display)
|
||||
{
|
||||
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
|
||||
}
|
||||
]
|
||||
};
|
||||
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));
|
||||
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 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));
|
||||
}
|
||||
|
||||
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; }
|
||||
}
|
||||
154
tests/Explorer.Application.Tests/CloudProviderTests.cs
Normal file
154
tests/Explorer.Application.Tests/CloudProviderTests.cs
Normal file
@@ -0,0 +1,154 @@
|
||||
using Explorer.Application;
|
||||
using Explorer.Domain;
|
||||
using Explorer.Plugin.Abstractions;
|
||||
using Explorer.Plugin.OneDrive;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace Explorer.Application.Tests;
|
||||
|
||||
public class CloudProviderTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Enrich_passes_through_when_provider_throws()
|
||||
{
|
||||
var registry = new StorageProviderRegistry([new ThrowingProvider()], NullLogger<StorageProviderRegistry>.Instance);
|
||||
var item = new FileSystemItem { FullPath = @"C:\a.txt", Name = "a.txt", SizeBytes = 10 };
|
||||
var result = await registry.EnrichAsync([item]);
|
||||
Assert.Single(result);
|
||||
Assert.Null(result[0].Cloud);
|
||||
Assert.Equal("a.txt", result[0].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Enrich_maps_provider_state()
|
||||
{
|
||||
var registry = new StorageProviderRegistry([new StubProvider()], NullLogger<StorageProviderRegistry>.Instance);
|
||||
var item = new FileSystemItem { FullPath = @"C:\OneDrive\photo.jpg", Name = "photo.jpg", SizeBytes = 100 };
|
||||
var result = await registry.EnrichAsync([item]);
|
||||
Assert.Equal(Explorer.Domain.CloudAvailability.OnlineOnly, result[0].Cloud?.Availability);
|
||||
Assert.Equal(8, result[0].AllocatedSizeBytes);
|
||||
Assert.True(result[0].Cloud?.MayHydrateOnRead);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Hydration_guard_skips_online_only_and_recall_attributes()
|
||||
{
|
||||
var registry = new StorageProviderRegistry([], NullLogger<StorageProviderRegistry>.Instance);
|
||||
var guard = new HydrationGuard(registry);
|
||||
Assert.True(guard.WouldHydrateOnRead(AttributeFlags.RecallOnDataAccess, null));
|
||||
Assert.True(guard.WouldHydrateOnRead(0, Explorer.Domain.CloudAvailability.OnlineOnly));
|
||||
Assert.False(guard.WouldHydrateOnRead(0, Explorer.Domain.CloudAvailability.LocallyAvailable));
|
||||
Assert.True(guard.WouldHydrateOnRead(new FileSystemItem
|
||||
{
|
||||
FullPath = @"C:\x",
|
||||
Name = "x",
|
||||
Cloud = new CloudPresence("onedrive", Explorer.Domain.CloudAvailability.OnlineOnly, 1, 0, true)
|
||||
}));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(OneDrivePlaceholderState.RecallOnDataAccess, null, Plugin.Abstractions.CloudAvailability.OnlineOnly)]
|
||||
[InlineData(OneDrivePlaceholderState.Pinned, null, Plugin.Abstractions.CloudAvailability.Pinned)]
|
||||
[InlineData(0, CloudFilesNativeStates.Invalid, Plugin.Abstractions.CloudAvailability.Error)]
|
||||
[InlineData(0, CloudFilesNativeStates.PlaceholderInSync, Plugin.Abstractions.CloudAvailability.LocallyAvailable)]
|
||||
public void OneDrive_maps_attributes_to_availability(int attributes, uint? cf, Plugin.Abstractions.CloudAvailability expected)
|
||||
{
|
||||
Assert.Equal(expected, OneDrivePlaceholderState.MapAvailability(attributes, cf));
|
||||
var state = OneDrivePlaceholderState.FromLocalSignals("onedrive", @"C:\OneDrive\f", attributes, 0, 50, 4, cf);
|
||||
Assert.Equal(expected, state.Availability);
|
||||
if (expected == Plugin.Abstractions.CloudAvailability.OnlineOnly)
|
||||
{
|
||||
Assert.True(state.MayHydrateOnRead);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Disabled_provider_is_ignored()
|
||||
{
|
||||
var registry = new StorageProviderRegistry([new StubProvider()], NullLogger<StorageProviderRegistry>.Instance);
|
||||
registry.SetEnabled("onedrive", false);
|
||||
Assert.Null(registry.Find(@"C:\OneDrive\a"));
|
||||
Assert.False(registry.HasCapability(@"C:\OneDrive\a", ProviderCapability.Pin));
|
||||
Assert.Empty(registry.GetPlaces());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetPlaces_fail_open_when_provider_throws()
|
||||
{
|
||||
var registry = new StorageProviderRegistry([new ThrowingPlacesProvider()], NullLogger<StorageProviderRegistry>.Instance);
|
||||
Assert.Empty(registry.GetPlaces());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Personal", @"C:\Users\x\OneDrive", null, "Dominique", "Dominique - Personal")]
|
||||
[InlineData("Business1", @"C:\Users\x\OneDrive - Contoso", "Contoso", "Dominique", "Dominique - Contoso")]
|
||||
[InlineData("Business1", @"C:\Users\x\OneDrive - Contoso", null, null, "OneDrive - Contoso")]
|
||||
[InlineData("Personal", @"C:\Users\x\OneDrive", null, null, "OneDrive")]
|
||||
public void OneDrive_place_label(string key, string folder, string? display, string? user, string expected)
|
||||
=> Assert.Equal(expected, OneDrivePlaceDiscovery.BuildLabel(key, folder, display, user));
|
||||
|
||||
[Fact]
|
||||
public void Cloud_place_store_merges_manual_without_duplicates()
|
||||
{
|
||||
var discovered = new ProviderPlace[]
|
||||
{
|
||||
new("onedrive", "Dominique - Personal", @"C:\Users\x\OneDrive")
|
||||
};
|
||||
var manual = new ProviderPlace[]
|
||||
{
|
||||
new("onedrive", "Work", @"C:\Users\x\OneDrive - Contoso"),
|
||||
new("onedrive", "Dup", @"C:\Users\x\OneDrive")
|
||||
};
|
||||
var merged = CloudPlaceStore.Merge(discovered, manual);
|
||||
Assert.Equal(2, merged.Count);
|
||||
Assert.Equal("Dominique - Personal", merged[0].DisplayName);
|
||||
Assert.Equal(@"C:\Users\x\OneDrive - Contoso", merged[1].Path);
|
||||
}
|
||||
}
|
||||
|
||||
file static class CloudFilesNativeStates
|
||||
{
|
||||
public const uint PlaceholderInSync = 0x1 | 0x8;
|
||||
public const uint Invalid = 0xFFFFFFFF;
|
||||
}
|
||||
|
||||
file sealed class ThrowingPlacesProvider : IStorageProvider
|
||||
{
|
||||
public ProviderManifest Manifest { get; } = new("boom", "Boom", "1", ProviderIsolation.InProcess);
|
||||
public ProviderCapability GetCapabilities() => ProviderCapability.CloudState;
|
||||
public bool TryMatchRoot(string path) => false;
|
||||
public IReadOnlyList<ProviderPlace> GetPlaces() => throw new InvalidOperationException("places failed");
|
||||
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)
|
||||
=> Task.FromResult<ProviderQuota?>(null);
|
||||
}
|
||||
|
||||
file sealed class ThrowingProvider : IStorageProvider
|
||||
{
|
||||
public ProviderManifest Manifest { get; } = new("boom", "Boom", "1", ProviderIsolation.InProcess);
|
||||
public ProviderCapability GetCapabilities() => ProviderCapability.CloudState;
|
||||
public bool TryMatchRoot(string path) => throw new InvalidOperationException("match failed");
|
||||
public Task<IReadOnlyList<ProviderItemState>> GetItemStatesAsync(IReadOnlyList<string> paths, CancellationToken cancellationToken = default)
|
||||
=> throw new InvalidOperationException("state failed");
|
||||
public Task<ProviderActionResult> TryInvokeAsync(ProviderActionRequest request, CancellationToken cancellationToken = default)
|
||||
=> throw new InvalidOperationException("invoke failed");
|
||||
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);
|
||||
public ProviderCapability GetCapabilities() => ProviderCapability.CloudState | ProviderCapability.Pin | ProviderCapability.Dehydrate;
|
||||
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>>(paths.Select(p => new ProviderItemState(
|
||||
"onedrive", p, Plugin.Abstractions.CloudAvailability.OnlineOnly, 100, 8, true, true, "Online-only", null)).ToList());
|
||||
public Task<ProviderActionResult> TryInvokeAsync(ProviderActionRequest request, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(new ProviderActionResult(ProviderActionStatus.Succeeded));
|
||||
public Task<ProviderQuota?> TryGetQuotaAsync(string rootPath, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<ProviderQuota?>(null);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0-windows</TargetFramework>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Explorer.Application\Explorer.Application.csproj" />
|
||||
<ProjectReference Include="..\..\src\Explorer.Domain\Explorer.Domain.csproj" />
|
||||
<ProjectReference Include="..\..\src\Explorer.Plugin.Abstractions\Explorer.Plugin.Abstractions.csproj" />
|
||||
<ProjectReference Include="..\..\src\Explorer.Plugin.OneDrive\Explorer.Plugin.OneDrive.csproj" />
|
||||
<ProjectReference Include="..\..\src\Explorer.Indexing\Explorer.Indexing.csproj" />
|
||||
<ProjectReference Include="..\..\src\Explorer.Storage.Sqlite\Explorer.Storage.Sqlite.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
49
tests/Explorer.Application.Tests/PathHistoryStoreTests.cs
Normal file
49
tests/Explorer.Application.Tests/PathHistoryStoreTests.cs
Normal file
@@ -0,0 +1,49 @@
|
||||
using Explorer.Application;
|
||||
using Explorer.Domain.Abstractions;
|
||||
|
||||
namespace Explorer.Application.Tests;
|
||||
|
||||
public class PathHistoryStoreTests
|
||||
{
|
||||
[Fact]
|
||||
public void Remember_moves_existing_path_to_front()
|
||||
{
|
||||
var next = PathHistoryStore.Remember(["D:\\Photos", "C:\\"], @"c:\");
|
||||
Assert.Equal(@"c:\", next[0]);
|
||||
Assert.Equal(2, next.Count);
|
||||
Assert.Equal(@"D:\Photos", next[1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_and_save_roundtrip()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "ew-path-history", Guid.NewGuid().ToString("N"));
|
||||
try
|
||||
{
|
||||
var store = new PathHistoryStore(new HistoryEnv(dir));
|
||||
store.Save(["C:\\Work", "This PC", " "]);
|
||||
var loaded = store.Load();
|
||||
Assert.Equal(["C:\\Work"], loaded);
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { Directory.Delete(dir, true); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
file sealed class HistoryEnv : IAppEnvironment
|
||||
{
|
||||
public HistoryEnv(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; }
|
||||
}
|
||||
123
tests/Explorer.Application.Tests/SourceManagerTests.cs
Normal file
123
tests/Explorer.Application.Tests/SourceManagerTests.cs
Normal file
@@ -0,0 +1,123 @@
|
||||
using Explorer.Application;
|
||||
using Explorer.Domain;
|
||||
using Explorer.Domain.Abstractions;
|
||||
using Explorer.Storage.Sqlite;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace Explorer.Application.Tests;
|
||||
|
||||
public class SourceManagerTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Removable_drive_keeps_stable_key_when_letter_changes()
|
||||
{
|
||||
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();
|
||||
volumes.Online =
|
||||
[
|
||||
new VolumeFingerprint
|
||||
{
|
||||
Kind = SourceKind.Removable,
|
||||
VolumeGuid = @"\\?\Volume{same}\",
|
||||
RootPath = @"E:\",
|
||||
DisplayName = "Stick E",
|
||||
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();
|
||||
var first = (await store.Sources.GetAllAsync()).Single();
|
||||
var key = first.StableKey;
|
||||
|
||||
volumes.Online =
|
||||
[
|
||||
new VolumeFingerprint
|
||||
{
|
||||
Kind = SourceKind.Removable,
|
||||
VolumeGuid = @"\\?\Volume{same}\",
|
||||
RootPath = @"G:\",
|
||||
DisplayName = "Stick G",
|
||||
VolumeSerial = 9,
|
||||
CapacityBytes = 64
|
||||
}
|
||||
];
|
||||
await mgr.RefreshOnlineStateAsync();
|
||||
var again = (await store.Sources.GetAllAsync()).Single();
|
||||
Assert.Equal(key, again.StableKey);
|
||||
Assert.Equal(@"G:\", again.LastRootPath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Ensure_for_mapped_letter_creates_source()
|
||||
{
|
||||
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);
|
||||
volumes.Online = [];
|
||||
await mgr.InitializeAsync();
|
||||
volumes.Online =
|
||||
[
|
||||
new VolumeFingerprint
|
||||
{
|
||||
Kind = SourceKind.Smb,
|
||||
RootPath = @"Z:\",
|
||||
DisplayName = "Z: (Network)",
|
||||
Filesystem = "SMB"
|
||||
}
|
||||
];
|
||||
var source = await mgr.EnsureForPathAsync(@"Z:\Photos");
|
||||
Assert.NotNull(source);
|
||||
Assert.Equal(@"Z:\", source!.LastRootPath);
|
||||
var found = await mgr.FindByPathAsync(@"Z:\");
|
||||
Assert.Equal(source.Id, found!.Id);
|
||||
}
|
||||
}
|
||||
|
||||
file sealed class FakeVolumes : IVolumeService
|
||||
{
|
||||
public List<VolumeFingerprint> Online { get; set; } = [];
|
||||
public IReadOnlyList<VolumeFingerprint> EnumerateOnlineVolumes() => Online;
|
||||
public VolumeFingerprint? Probe(string path)
|
||||
{
|
||||
var root = Path.GetPathRoot(path)?.TrimEnd('\\');
|
||||
return Online.FirstOrDefault(v =>
|
||||
v.RootPath.TrimEnd('\\').Equals(root, StringComparison.OrdinalIgnoreCase)
|
||||
|| v.RootPath.Equals(path, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
public bool IsPathReachable(string path) => Online.Any(v => path.StartsWith(v.RootPath.TrimEnd('\\'), StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
file sealed class FakeEnv : IAppEnvironment
|
||||
{
|
||||
public FakeEnv(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; }
|
||||
}
|
||||
156
tests/Explorer.Domain.Tests/DomainTests.cs
Normal file
156
tests/Explorer.Domain.Tests/DomainTests.cs
Normal file
@@ -0,0 +1,156 @@
|
||||
using Explorer.Domain;
|
||||
|
||||
namespace Explorer.Domain.Tests;
|
||||
|
||||
public class NameNormalizerTests
|
||||
{
|
||||
[Fact]
|
||||
public void Normalize_folds_case_and_keeps_unicode()
|
||||
{
|
||||
Assert.Equal("straße", NameNormalizer.Normalize("Straße"));
|
||||
Assert.Equal("abc", NameNormalizer.Normalize("ABC"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Extension_skips_leading_dot_names()
|
||||
{
|
||||
Assert.Equal("mkv", NameNormalizer.Extension("film.mkv"));
|
||||
Assert.Null(NameNormalizer.Extension(".gitignore"));
|
||||
Assert.Null(NameNormalizer.Extension("Makefile"));
|
||||
}
|
||||
}
|
||||
|
||||
public class PathRulesTests
|
||||
{
|
||||
[Fact]
|
||||
public void Extended_roundtrip_local_and_unc()
|
||||
{
|
||||
Assert.Equal(@"\\?\C:\Temp", PathRules.ToExtended(@"C:\Temp"));
|
||||
Assert.Equal(@"\\?\UNC\server\share", PathRules.ToExtended(@"\\server\share"));
|
||||
Assert.Equal(@"C:\Temp", PathRules.FromExtended(@"\\?\C:\Temp"));
|
||||
Assert.Equal(@"\\server\share", PathRules.FromExtended(@"\\?\UNC\server\share"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Canonical_unc_lowercases_server()
|
||||
{
|
||||
Assert.Equal(@"\\media\Movies", PathRules.CanonicalUncRoot(@"\\Media\Movies\Action"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Relative_and_parent()
|
||||
{
|
||||
Assert.Equal(@"Windows\System32", PathRules.MakeRelative(@"C:\", @"C:\Windows\System32"));
|
||||
Assert.Equal(@"C:\Windows", PathRules.Parent(@"C:\Windows\System32"));
|
||||
Assert.Equal(@"C:\", PathRules.Parent(@"C:\Windows"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Join_display_combines_root_and_relative()
|
||||
{
|
||||
Assert.Equal(@"C:\Users\Docs", PathRules.JoinDisplay(@"C:\Users", "Docs"));
|
||||
Assert.Equal(@"C:\", PathRules.JoinDisplay(@"C:\", ""));
|
||||
Assert.Equal("Docs", PathRules.JoinDisplay(null, "Docs"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Shorten_keeps_root_and_leaf()
|
||||
{
|
||||
Assert.Equal(@"C:\Temp", PathRules.ShortenDisplay(@"C:\Temp"));
|
||||
Assert.Equal(@"C:\Users\…\Documents\Budget", PathRules.ShortenDisplay(@"C:\Users\Dominique\Documents\Budget", 28));
|
||||
Assert.Equal(@"\\media\movies\…\Action\Dune", PathRules.ShortenDisplay(@"\\media\movies\New\Action\Dune", 28));
|
||||
}
|
||||
}
|
||||
|
||||
public class VolumeIdentityTests
|
||||
{
|
||||
[Fact]
|
||||
public void Matches_volume_guid_across_drive_letters()
|
||||
{
|
||||
var known = new List<Source>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Id = 1,
|
||||
StableKey = "a",
|
||||
DisplayName = "SanDisk Ultra 64 GB",
|
||||
VolumeGuid = @"\\?\Volume{abc}\",
|
||||
LastRootPath = @"E:\",
|
||||
Kind = SourceKind.Removable
|
||||
}
|
||||
};
|
||||
var fp = new VolumeFingerprint
|
||||
{
|
||||
Kind = SourceKind.Removable,
|
||||
VolumeGuid = @"\\?\Volume{abc}\",
|
||||
RootPath = @"G:\",
|
||||
DisplayName = "SanDisk"
|
||||
};
|
||||
var match = VolumeIdentityMatcher.Match(fp, known);
|
||||
Assert.NotNull(match.Source);
|
||||
Assert.Equal(1, match.Source!.Id);
|
||||
Assert.Equal(VolumeIdentityMatcher.MatchStrength.Guid, match.Strength);
|
||||
Assert.False(match.Ambiguous);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Matches_unc_share()
|
||||
{
|
||||
var known = new List<Source>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Id = 7,
|
||||
StableKey = "s",
|
||||
DisplayName = @"\\media\movies",
|
||||
Kind = SourceKind.Smb,
|
||||
LastRootPath = @"\\Media\Movies"
|
||||
}
|
||||
};
|
||||
var fp = new VolumeFingerprint { Kind = SourceKind.Smb, RootPath = @"\\media\movies", DisplayName = "x" };
|
||||
Assert.Equal(7, VolumeIdentityMatcher.Match(fp, known).Source?.Id);
|
||||
}
|
||||
}
|
||||
|
||||
public class ExcludeEvaluatorTests
|
||||
{
|
||||
[Fact]
|
||||
public void Excludes_path_glob_and_extension()
|
||||
{
|
||||
var ev = new ExcludeEvaluator(
|
||||
[
|
||||
new() { Kind = ExcludeKind.PathPrefix, Pattern = @"C:\Windows" },
|
||||
new() { Kind = ExcludeKind.Glob, Pattern = "node_modules" },
|
||||
new() { Kind = ExcludeKind.Extension, Pattern = "tmp" }
|
||||
], skipHidden: false, skipSystem: false);
|
||||
|
||||
Assert.True(ev.ShouldExclude(@"C:\Windows\System32", "System32", true, 0, null));
|
||||
Assert.True(ev.ShouldExclude(@"D:\proj\node_modules", "node_modules", true, 0, null));
|
||||
Assert.True(ev.ShouldExclude(@"D:\a.tmp", "a.tmp", false, 0, null));
|
||||
Assert.False(ev.ShouldExclude(@"D:\a.txt", "a.txt", false, 0, null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Hidden_policy()
|
||||
{
|
||||
var ev = new ExcludeEvaluator([], skipHidden: true, skipSystem: true);
|
||||
Assert.True(ev.ShouldExclude(@"C:\x", "x", false, AttributeFlags.Hidden, null));
|
||||
}
|
||||
}
|
||||
|
||||
public class ReparsePolicyTests
|
||||
{
|
||||
[Fact]
|
||||
public void Does_not_recurse_reparse_directories()
|
||||
{
|
||||
var item = new FileSystemItem
|
||||
{
|
||||
FullPath = @"C:\link",
|
||||
Name = "link",
|
||||
IsDirectory = true,
|
||||
Attributes = AttributeFlags.Directory | AttributeFlags.ReparsePoint,
|
||||
ReparseTag = ReparsePolicy.IoReparseTagSymlink
|
||||
};
|
||||
Assert.False(ReparsePolicy.ShouldRecurseIntoDirectory(item));
|
||||
}
|
||||
}
|
||||
15
tests/Explorer.Domain.Tests/Explorer.Domain.Tests.csproj
Normal file
15
tests/Explorer.Domain.Tests/Explorer.Domain.Tests.csproj
Normal file
@@ -0,0 +1,15 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Explorer.Domain\Explorer.Domain.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,18 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Explorer.Domain\Explorer.Domain.csproj" />
|
||||
<ProjectReference Include="..\..\src\Explorer.FileOperations\Explorer.FileOperations.csproj" />
|
||||
<ProjectReference Include="..\..\src\Explorer.Application\Explorer.Application.csproj" />
|
||||
<ProjectReference Include="..\..\src\Explorer.Storage.Sqlite\Explorer.Storage.Sqlite.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
44
tests/Explorer.FileOperations.Tests/FileOperationTests.cs
Normal file
44
tests/Explorer.FileOperations.Tests/FileOperationTests.cs
Normal file
@@ -0,0 +1,44 @@
|
||||
using Explorer.Domain;
|
||||
using Explorer.Domain.Abstractions;
|
||||
using Explorer.FileOperations;
|
||||
|
||||
namespace Explorer.FileOperations.Tests;
|
||||
|
||||
public class FileOperationTests
|
||||
{
|
||||
[Fact]
|
||||
public void New_folder_and_rename_on_real_filesystem()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "ew-ops", Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(root);
|
||||
try
|
||||
{
|
||||
var ops = new FileOperationService(null!, new StubShell(), new StubEnum());
|
||||
var created = ops.NewFolder(root);
|
||||
Assert.True(Directory.Exists(created));
|
||||
ops.Rename(created, "Renamed");
|
||||
Assert.True(Directory.Exists(Path.Combine(root, "Renamed")));
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { Directory.Delete(root, true); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
file sealed class StubEnum : IFileSystemEnumerator
|
||||
{
|
||||
public IEnumerable<FileSystemItem> EnumerateChildren(string directoryPath) => [];
|
||||
public IReadOnlyList<FileSystemItem> EnumerateChildrenSafe(string directoryPath, out string? error) { error = null; return []; }
|
||||
public FileSystemItem? GetItem(string path) => null;
|
||||
}
|
||||
|
||||
file sealed class StubShell : IShellFileOperations
|
||||
{
|
||||
public void Open(string path) { }
|
||||
public bool DeleteToRecycleBin(IReadOnlyList<string> paths, out string? error) { error = "n/a"; return false; }
|
||||
public bool CopyFileWithProgress(string source, string destination, bool overwrite, IProgress<long>? progress, CancellationToken cancellationToken, out string? error)
|
||||
{ error = "n/a"; return false; }
|
||||
public bool MoveFileWithProgress(string source, string destination, bool overwrite, IProgress<long>? progress, CancellationToken cancellationToken, out string? error)
|
||||
{ error = "n/a"; return false; }
|
||||
}
|
||||
18
tests/Explorer.Indexing.Tests/Explorer.Indexing.Tests.csproj
Normal file
18
tests/Explorer.Indexing.Tests/Explorer.Indexing.Tests.csproj
Normal file
@@ -0,0 +1,18 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Explorer.Domain\Explorer.Domain.csproj" />
|
||||
<ProjectReference Include="..\..\src\Explorer.Indexing\Explorer.Indexing.csproj" />
|
||||
<ProjectReference Include="..\..\src\Explorer.Application\Explorer.Application.csproj" />
|
||||
<ProjectReference Include="..\..\src\Explorer.Storage.Sqlite\Explorer.Storage.Sqlite.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
221
tests/Explorer.Indexing.Tests/ScannerTests.cs
Normal file
221
tests/Explorer.Indexing.Tests/ScannerTests.cs
Normal file
@@ -0,0 +1,221 @@
|
||||
using Explorer.Application;
|
||||
using Explorer.Domain;
|
||||
using Explorer.Domain.Abstractions;
|
||||
using Explorer.Indexing;
|
||||
using Explorer.Storage.Sqlite;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace Explorer.Indexing.Tests;
|
||||
|
||||
public sealed class IoEnumerator : IFileSystemEnumerator
|
||||
{
|
||||
public IEnumerable<FileSystemItem> EnumerateChildren(string directoryPath)
|
||||
=> EnumerateChildrenSafe(directoryPath, out _);
|
||||
|
||||
public IReadOnlyList<FileSystemItem> EnumerateChildrenSafe(string directoryPath, out string? error)
|
||||
{
|
||||
error = null;
|
||||
try
|
||||
{
|
||||
return Directory.EnumerateFileSystemEntries(directoryPath)
|
||||
.Select(p => GetItem(p)!)
|
||||
.Where(i => i is not null)
|
||||
.ToList()!;
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
error = "Access denied";
|
||||
return [];
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
error = ex.Message;
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public FileSystemItem? GetItem(string path)
|
||||
{
|
||||
if (Directory.Exists(path))
|
||||
{
|
||||
var d = new DirectoryInfo(path);
|
||||
return new FileSystemItem
|
||||
{
|
||||
FullPath = d.FullName,
|
||||
Name = d.Name,
|
||||
IsDirectory = true,
|
||||
Attributes = (int)d.Attributes,
|
||||
CreatedUtc = d.CreationTimeUtc,
|
||||
ModifiedUtc = d.LastWriteTimeUtc
|
||||
};
|
||||
}
|
||||
|
||||
if (File.Exists(path))
|
||||
{
|
||||
var f = new FileInfo(path);
|
||||
return new FileSystemItem
|
||||
{
|
||||
FullPath = f.FullName,
|
||||
Name = f.Name,
|
||||
IsDirectory = false,
|
||||
SizeBytes = f.Length,
|
||||
Attributes = (int)f.Attributes,
|
||||
CreatedUtc = f.CreationTimeUtc,
|
||||
ModifiedUtc = f.LastWriteTimeUtc
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public class ScannerTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Full_scan_indexes_tree_and_folder_sizes()
|
||||
{
|
||||
var root = CreateTree();
|
||||
try
|
||||
{
|
||||
await using var store = await OpenStore();
|
||||
var source = await AddSource(store, root);
|
||||
var scanner = new FilesystemScanner(store, new IoEnumerator(), new StorageProviderRegistry([], NullLogger<StorageProviderRegistry>.Instance), NullLogger<FilesystemScanner>.Instance);
|
||||
var job = await scanner.ScanAsync(source, ScanKind.Full, null, null, CancellationToken.None);
|
||||
Assert.Equal(ScanJobStatus.Done, job.Status);
|
||||
Assert.True(job.FilesSeen >= 2);
|
||||
var movies = await store.Entries.GetByPathAsync(source.Id, "Movies");
|
||||
Assert.NotNull(movies);
|
||||
Assert.True(movies!.AggregateSize >= 50);
|
||||
var hit = await store.Search.SearchAsync(new SearchRequest { Name = "a.txt", SourceIds = [source.Id] });
|
||||
Assert.Contains(hit, e => e.Name == "a.txt");
|
||||
}
|
||||
finally
|
||||
{
|
||||
TryDelete(root);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Repeated_scan_upserts_without_duplicating()
|
||||
{
|
||||
var root = CreateTree();
|
||||
try
|
||||
{
|
||||
await using var store = await OpenStore();
|
||||
var source = await AddSource(store, root);
|
||||
var scanner = new FilesystemScanner(store, new IoEnumerator(), new StorageProviderRegistry([], NullLogger<StorageProviderRegistry>.Instance), NullLogger<FilesystemScanner>.Instance);
|
||||
await scanner.ScanAsync(source, ScanKind.Full, null, null, CancellationToken.None);
|
||||
source = (await store.Sources.GetAsync(source.Id))!;
|
||||
await scanner.ScanAsync(source, ScanKind.Full, null, null, CancellationToken.None);
|
||||
var count = await store.Entries.CountPresentAsync(source.Id);
|
||||
var second = await store.Entries.CountPresentAsync(source.Id);
|
||||
Assert.Equal(count, second);
|
||||
}
|
||||
finally
|
||||
{
|
||||
TryDelete(root);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Cancelled_scan_keeps_committed_batches()
|
||||
{
|
||||
var root = CreateTree();
|
||||
try
|
||||
{
|
||||
await using var store = await OpenStore();
|
||||
var source = await AddSource(store, root);
|
||||
var scanner = new FilesystemScanner(store, new IoEnumerator(), new StorageProviderRegistry([], NullLogger<StorageProviderRegistry>.Instance), NullLogger<FilesystemScanner>.Instance);
|
||||
using var cts = new CancellationTokenSource();
|
||||
cts.Cancel();
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(() =>
|
||||
scanner.ScanAsync(source, ScanKind.Full, null, null, cts.Token));
|
||||
var loaded = await store.Sources.GetAsync(source.Id);
|
||||
Assert.Equal(SourceStatus.Stale, loaded!.Status);
|
||||
}
|
||||
finally
|
||||
{
|
||||
TryDelete(root);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Excludes_skip_matching_names()
|
||||
{
|
||||
var root = CreateTree();
|
||||
Directory.CreateDirectory(Path.Combine(root, "node_modules"));
|
||||
await File.WriteAllTextAsync(Path.Combine(root, "node_modules", "x.js"), "x");
|
||||
try
|
||||
{
|
||||
await using var store = await OpenStore();
|
||||
await store.Excludes.EnsureDefaultsAsync(DefaultExcludes.Create());
|
||||
var source = await AddSource(store, root);
|
||||
var scanner = new FilesystemScanner(store, new IoEnumerator(), new StorageProviderRegistry([], NullLogger<StorageProviderRegistry>.Instance), NullLogger<FilesystemScanner>.Instance);
|
||||
await scanner.ScanAsync(source, ScanKind.Full, null, null, CancellationToken.None);
|
||||
var hit = await store.Entries.GetByPathAsync(source.Id, "node_modules");
|
||||
Assert.Null(hit);
|
||||
}
|
||||
finally
|
||||
{
|
||||
TryDelete(root);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Folder_reconcile_tombs_removed_file()
|
||||
{
|
||||
var root = CreateTree();
|
||||
try
|
||||
{
|
||||
await using var store = await OpenStore();
|
||||
var source = await AddSource(store, root);
|
||||
var scanner = new FilesystemScanner(store, new IoEnumerator(), new StorageProviderRegistry([], NullLogger<StorageProviderRegistry>.Instance), NullLogger<FilesystemScanner>.Instance);
|
||||
await scanner.ScanAsync(source, ScanKind.Full, null, null, CancellationToken.None);
|
||||
File.Delete(Path.Combine(root, "a.txt"));
|
||||
var reconciler = new FolderReconciler(store, new IoEnumerator(), new StorageProviderRegistry([], NullLogger<StorageProviderRegistry>.Instance));
|
||||
await reconciler.ReconcileAsync(source, "", CancellationToken.None);
|
||||
var entry = await store.Entries.GetByPathAsync(source.Id, "a.txt");
|
||||
Assert.Equal(EntryStatus.Deleted, entry!.Status);
|
||||
}
|
||||
finally
|
||||
{
|
||||
TryDelete(root);
|
||||
}
|
||||
}
|
||||
|
||||
private static string CreateTree()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "ew-scan", Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(Path.Combine(root, "Movies"));
|
||||
File.WriteAllText(Path.Combine(root, "a.txt"), new string('a', 100));
|
||||
File.WriteAllText(Path.Combine(root, "Movies", "b.txt"), new string('b', 50));
|
||||
return root;
|
||||
}
|
||||
|
||||
private static async Task<SqliteIndexStore> OpenStore()
|
||||
{
|
||||
var db = Path.Combine(Path.GetTempPath(), "ew-scan", Guid.NewGuid().ToString("N"), "index.db");
|
||||
var store = new SqliteIndexStore(db, NullLogger<SqliteIndexStore>.Instance);
|
||||
await store.OpenAsync();
|
||||
return store;
|
||||
}
|
||||
|
||||
private static async Task<Source> AddSource(IIndexStore store, string root)
|
||||
{
|
||||
var s = new Source
|
||||
{
|
||||
StableKey = Guid.NewGuid().ToString("N"),
|
||||
Kind = SourceKind.NtfsLocal,
|
||||
DisplayName = "t",
|
||||
LastRootPath = root,
|
||||
Status = SourceStatus.Online
|
||||
};
|
||||
s.Id = await store.Sources.UpsertAsync(s);
|
||||
return s;
|
||||
}
|
||||
|
||||
private static void TryDelete(string root)
|
||||
{
|
||||
try { Directory.Delete(root, true); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
18
tests/Explorer.Search.Tests/Explorer.Search.Tests.csproj
Normal file
18
tests/Explorer.Search.Tests/Explorer.Search.Tests.csproj
Normal file
@@ -0,0 +1,18 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Explorer.Domain\Explorer.Domain.csproj" />
|
||||
<ProjectReference Include="..\..\src\Explorer.Search\Explorer.Search.csproj" />
|
||||
<ProjectReference Include="..\..\src\Explorer.Storage.Sqlite\Explorer.Storage.Sqlite.csproj" />
|
||||
<ProjectReference Include="..\..\src\Explorer.Application\Explorer.Application.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
47
tests/Explorer.Search.Tests/SearchServiceTests.cs
Normal file
47
tests/Explorer.Search.Tests/SearchServiceTests.cs
Normal file
@@ -0,0 +1,47 @@
|
||||
using Explorer.Domain;
|
||||
using Explorer.Search;
|
||||
using Explorer.Storage.Sqlite;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace Explorer.Search.Tests;
|
||||
|
||||
public class SearchServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Glob_and_size_and_directory_filters()
|
||||
{
|
||||
var db = Path.Combine(Path.GetTempPath(), "ew-search", Guid.NewGuid().ToString("N"), "index.db");
|
||||
await using var store = new SqliteIndexStore(db, NullLogger<SqliteIndexStore>.Instance);
|
||||
await store.OpenAsync();
|
||||
var source = new Source { StableKey = "k", DisplayName = "d", Kind = SourceKind.NtfsLocal, LastRootPath = @"C:\d", Status = SourceStatus.Online };
|
||||
source.Id = await store.Sources.UpsertAsync(source);
|
||||
var root = new IndexEntry { SourceId = source.Id, Name = "d", NameNorm = "d", 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 = "clip.mkv", NameNorm = "clip.mkv", Extension = "mkv",
|
||||
SizeBytes = 20_000_000, PathRel = "clip.mkv", LastSeenUtc = DateTimeOffset.UtcNow
|
||||
});
|
||||
await store.Entries.UpsertAsync(new IndexEntry
|
||||
{
|
||||
SourceId = source.Id, ParentId = root.Id, Name = "notes.txt", NameNorm = "notes.txt", Extension = "txt",
|
||||
SizeBytes = 10, PathRel = "notes.txt", LastSeenUtc = DateTimeOffset.UtcNow
|
||||
});
|
||||
await store.Entries.UpsertAsync(new IndexEntry
|
||||
{
|
||||
SourceId = source.Id, ParentId = root.Id, Name = "Folder", NameNorm = "folder", IsDirectory = true,
|
||||
PathRel = "Folder", LastSeenUtc = DateTimeOffset.UtcNow
|
||||
});
|
||||
|
||||
var search = new SearchService(store);
|
||||
var mkv = await search.SearchAsync(new SearchQuery { Text = "*.mkv", SourceIds = [source.Id] });
|
||||
Assert.Single(mkv);
|
||||
var big = await search.SearchAsync(new SearchQuery { MinSize = 1_000_000, SourceIds = [source.Id] });
|
||||
Assert.Single(big);
|
||||
var dirs = await search.SearchAsync(new SearchQuery { IsDirectory = true, SourceIds = [source.Id] });
|
||||
Assert.Contains(dirs, e => e.Name == "Folder");
|
||||
var both = await search.SearchAsync(new SearchQuery { SourceIds = [source.Id], IsDirectory = null });
|
||||
Assert.Contains(both, e => e.Name == "Folder");
|
||||
Assert.Contains(both, e => e.Name == "notes.txt");
|
||||
}
|
||||
}
|
||||
20
tests/Explorer.Storage.Tests/Explorer.Storage.Tests.csproj
Normal file
20
tests/Explorer.Storage.Tests/Explorer.Storage.Tests.csproj
Normal file
@@ -0,0 +1,20 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Explorer.Domain\Explorer.Domain.csproj" />
|
||||
<ProjectReference Include="..\..\src\Explorer.Storage.Sqlite\Explorer.Storage.Sqlite.csproj" />
|
||||
<ProjectReference Include="..\..\src\Explorer.Application\Explorer.Application.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<None Update="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
193
tests/Explorer.Storage.Tests/StorageTests.cs
Normal file
193
tests/Explorer.Storage.Tests/StorageTests.cs
Normal file
@@ -0,0 +1,193 @@
|
||||
using Explorer.Domain;
|
||||
using Explorer.Domain.Abstractions;
|
||||
using Explorer.Storage.Sqlite;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace Explorer.Storage.Tests;
|
||||
|
||||
internal static class Stores
|
||||
{
|
||||
public static async Task<SqliteIndexStore> Open()
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), "ew-tests", Guid.NewGuid().ToString("N"), "index.db");
|
||||
var store = new SqliteIndexStore(path, NullLogger<SqliteIndexStore>.Instance);
|
||||
await store.OpenAsync();
|
||||
return store;
|
||||
}
|
||||
|
||||
public static async Task<Source> AddSourceAsync(this IIndexStore store, string root)
|
||||
{
|
||||
var s = new Source
|
||||
{
|
||||
StableKey = Guid.NewGuid().ToString("N"),
|
||||
Kind = SourceKind.NtfsLocal,
|
||||
DisplayName = "Test",
|
||||
LastRootPath = root,
|
||||
Status = SourceStatus.Online
|
||||
};
|
||||
s.Id = await store.Sources.UpsertAsync(s);
|
||||
return s;
|
||||
}
|
||||
}
|
||||
|
||||
public class SchemaTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Opens_and_quick_check_ok()
|
||||
{
|
||||
await using var store = await Stores.Open();
|
||||
var check = await store.QuickCheckAsync();
|
||||
Assert.Equal("ok", check, ignoreCase: true);
|
||||
await store.Analysis.EnsureReadyAsync();
|
||||
await store.Analysis.EnsureReadyAsync();
|
||||
Assert.Empty(await store.Analysis.GetDirectoryRootsAsync());
|
||||
Assert.Equal(await store.Analysis.GetIndexStampAsync(), await store.Analysis.GetIndexStampAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Source_roundtrip_and_identity_fields()
|
||||
{
|
||||
await using var store = await Stores.Open();
|
||||
var source = await store.AddSourceAsync(@"T:\");
|
||||
Assert.True(source.Id > 0);
|
||||
source.VolumeGuid = @"\\?\Volume{xyz}\";
|
||||
source.VolumeSerial = 1234;
|
||||
await store.Sources.UpsertAsync(source);
|
||||
var loaded = await store.Sources.GetAsync(source.Id);
|
||||
Assert.Equal(@"\\?\Volume{xyz}\", loaded!.VolumeGuid);
|
||||
Assert.Equal(1234, loaded.VolumeSerial);
|
||||
}
|
||||
}
|
||||
|
||||
public class EntryAndSearchTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Upsert_is_idempotent_and_search_filters_work()
|
||||
{
|
||||
await using var store = await Stores.Open();
|
||||
var source = await store.AddSourceAsync(@"C:\data");
|
||||
var root = new IndexEntry
|
||||
{
|
||||
SourceId = source.Id,
|
||||
Name = "data",
|
||||
NameNorm = "data",
|
||||
IsDirectory = true,
|
||||
PathRel = "",
|
||||
LastSeenUtc = DateTimeOffset.UtcNow,
|
||||
ScanGeneration = 1,
|
||||
Status = EntryStatus.Present
|
||||
};
|
||||
root.Id = await store.Entries.UpsertAsync(root);
|
||||
var file = new IndexEntry
|
||||
{
|
||||
SourceId = source.Id,
|
||||
ParentId = root.Id,
|
||||
Name = "Movie.mkv",
|
||||
NameNorm = "movie.mkv",
|
||||
Extension = "mkv",
|
||||
IsDirectory = false,
|
||||
SizeBytes = 12L * 1024 * 1024 * 1024,
|
||||
PathRel = "Movie.mkv",
|
||||
LastSeenUtc = DateTimeOffset.UtcNow,
|
||||
ModifiedUtc = DateTimeOffset.Parse("2024-06-01Z"),
|
||||
ScanGeneration = 1
|
||||
};
|
||||
var id1 = await store.Entries.UpsertAsync(file);
|
||||
file.SizeBytes = 13L * 1024 * 1024 * 1024;
|
||||
var id2 = await store.Entries.UpsertAsync(file);
|
||||
Assert.Equal(id1, id2);
|
||||
|
||||
var found = await store.Search.SearchAsync(new SearchRequest
|
||||
{
|
||||
Name = "*.mkv",
|
||||
MinSize = 10L * 1024 * 1024 * 1024,
|
||||
SourceIds = [source.Id],
|
||||
Take = 50
|
||||
});
|
||||
Assert.Contains(found, e => e.Name == "Movie.mkv");
|
||||
|
||||
var byDate = await store.Search.SearchAsync(new SearchRequest
|
||||
{
|
||||
ModifiedBefore = DateTimeOffset.Parse("2025-01-01Z"),
|
||||
SourceIds = [source.Id]
|
||||
});
|
||||
Assert.Contains(byDate, e => e.Name == "Movie.mkv");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Folder_aggregates_and_tombstones()
|
||||
{
|
||||
await using var store = await Stores.Open();
|
||||
var source = await store.AddSourceAsync(@"C:\lib");
|
||||
var root = new IndexEntry { SourceId = source.Id, Name = "lib", NameNorm = "lib", IsDirectory = true, PathRel = "", LastSeenUtc = DateTimeOffset.UtcNow };
|
||||
root.Id = await store.Entries.UpsertAsync(root);
|
||||
var dir = new IndexEntry { SourceId = source.Id, ParentId = root.Id, Name = "Movies", NameNorm = "movies", IsDirectory = true, PathRel = "Movies", LastSeenUtc = DateTimeOffset.UtcNow };
|
||||
dir.Id = await store.Entries.UpsertAsync(dir);
|
||||
var file = new IndexEntry
|
||||
{
|
||||
SourceId = source.Id,
|
||||
ParentId = dir.Id,
|
||||
Name = "a.bin",
|
||||
NameNorm = "a.bin",
|
||||
IsDirectory = false,
|
||||
SizeBytes = 1000,
|
||||
PathRel = @"Movies\a.bin",
|
||||
LastSeenUtc = DateTimeOffset.UtcNow
|
||||
};
|
||||
file.Id = await store.Entries.UpsertAsync(file);
|
||||
await store.Entries.ApplySizeDeltaToAncestorsAsync(dir.Id, 1000, 1, 0);
|
||||
var loadedDir = await store.Entries.GetAsync(dir.Id);
|
||||
Assert.Equal(1000, loadedDir!.AggregateSize);
|
||||
await store.Entries.TombstoneAsync(file.Id, DateTimeOffset.UtcNow);
|
||||
loadedDir = await store.Entries.GetAsync(dir.Id);
|
||||
Assert.Equal(0, loadedDir!.AggregateSize);
|
||||
var tomb = await store.Entries.GetAsync(file.Id);
|
||||
Assert.Equal(EntryStatus.Deleted, tomb!.Status);
|
||||
await store.Entries.DeleteExpiredTombstonesAsync(DateTimeOffset.UtcNow.AddDays(1));
|
||||
Assert.Null(await store.Entries.GetAsync(file.Id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Rename_updates_subtree_paths()
|
||||
{
|
||||
await using var store = await Stores.Open();
|
||||
var source = await store.AddSourceAsync(@"C:\r");
|
||||
var root = new IndexEntry { SourceId = source.Id, Name = "r", NameNorm = "r", IsDirectory = true, PathRel = "", LastSeenUtc = DateTimeOffset.UtcNow };
|
||||
root.Id = await store.Entries.UpsertAsync(root);
|
||||
var dir = new IndexEntry { SourceId = source.Id, ParentId = root.Id, Name = "Old", NameNorm = "old", IsDirectory = true, PathRel = "Old", LastSeenUtc = DateTimeOffset.UtcNow };
|
||||
dir.Id = await store.Entries.UpsertAsync(dir);
|
||||
var file = new IndexEntry { SourceId = source.Id, ParentId = dir.Id, Name = "f.txt", NameNorm = "f.txt", PathRel = @"Old\f.txt", LastSeenUtc = DateTimeOffset.UtcNow };
|
||||
file.Id = await store.Entries.UpsertAsync(file);
|
||||
await store.Entries.RenameSubtreePathAsync(source.Id, "Old", "New");
|
||||
var moved = await store.Entries.GetAsync(file.Id);
|
||||
Assert.Equal(@"New\f.txt", moved!.PathRel);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Duplicate_candidates_are_same_size_groups()
|
||||
{
|
||||
await using var store = await Stores.Open();
|
||||
var source = await store.AddSourceAsync(@"C:\d");
|
||||
var root = new IndexEntry { SourceId = source.Id, Name = "d", NameNorm = "d", IsDirectory = true, PathRel = "", LastSeenUtc = DateTimeOffset.UtcNow };
|
||||
root.Id = await store.Entries.UpsertAsync(root);
|
||||
foreach (var name in new[] { "a.bin", "b.bin", "c.bin" })
|
||||
{
|
||||
await store.Entries.UpsertAsync(new IndexEntry
|
||||
{
|
||||
SourceId = source.Id,
|
||||
ParentId = root.Id,
|
||||
Name = name,
|
||||
NameNorm = name,
|
||||
IsDirectory = false,
|
||||
SizeBytes = name == "c.bin" ? 50 : 100,
|
||||
PathRel = name,
|
||||
LastSeenUtc = DateTimeOffset.UtcNow
|
||||
});
|
||||
}
|
||||
|
||||
await store.Hashes.EnqueueSizeCollisionsAsync(source.Id);
|
||||
var work = await store.Hashes.DequeueAsync(10);
|
||||
Assert.Equal(2, work.Count);
|
||||
Assert.All(work, w => Assert.Equal(100, w.SizeBytes));
|
||||
}
|
||||
}
|
||||
5
tests/Explorer.Storage.Tests/xunit.runner.json
Normal file
5
tests/Explorer.Storage.Tests/xunit.runner.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"$schema": "https://xunit.net/schema/current/xunit.runner.schema.json",
|
||||
"parallelizeAssembly": false,
|
||||
"parallelizeTestCollections": false
|
||||
}
|
||||
Reference in New Issue
Block a user