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:
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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user