Add host activity and DB browser, and keep dialogs, drag-drop, and idle maintenance responsive.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -178,10 +178,44 @@ public class AnalysisTests
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Missing_files_are_tombstoned_when_loading_duplicates()
|
||||
{
|
||||
var (store, analysis, source) = await SeedHashedAsync(fileIdA: 1, fileIdB: 2);
|
||||
await using (store)
|
||||
{
|
||||
File.Delete(Path.Combine(source.LastRootPath!, "a.bin"));
|
||||
var classified = await analysis.GetClassifiedDuplicatesAsync();
|
||||
Assert.Empty(classified);
|
||||
var gone = await store.Entries.GetByPathAsync(source.Id, "a.bin");
|
||||
Assert.Equal(EntryStatus.Deleted, gone!.Status);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Remaining_copies_still_show_when_one_duplicate_is_gone()
|
||||
{
|
||||
var (store, analysis, source) = await SeedHashedAsync(fileIdA: 1, fileIdB: 2);
|
||||
await using (store)
|
||||
{
|
||||
File.WriteAllBytes(Path.Combine(source.LastRootPath!, "c.bin"), new byte[64]);
|
||||
var root = await store.Entries.GetByPathAsync(source.Id, "");
|
||||
await store.Entries.UpsertAsync(Hashed("c.bin", 3, source.Id, root!.Id, Enumerable.Repeat((byte)7, 32).ToArray()));
|
||||
File.Delete(Path.Combine(source.LastRootPath!, "a.bin"));
|
||||
var classified = Assert.Single(await analysis.GetClassifiedDuplicatesAsync());
|
||||
Assert.Equal(2, classified.Group.Entries.Count);
|
||||
Assert.DoesNotContain(classified.Group.Entries, e => e.PathRel == "a.bin");
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<(SqliteIndexStore Store, AnalysisService Analysis, Source Source)> SeedHashedAsync(
|
||||
long fileIdA, long fileIdB)
|
||||
{
|
||||
var db = Path.Combine(Path.GetTempPath(), "ew-dup", Guid.NewGuid().ToString("N"), "index.db");
|
||||
var rootPath = Path.Combine(Path.GetTempPath(), "ew-dup-fs", Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(rootPath);
|
||||
File.WriteAllBytes(Path.Combine(rootPath, "a.bin"), new byte[64]);
|
||||
File.WriteAllBytes(Path.Combine(rootPath, "b.bin"), new byte[64]);
|
||||
var store = new SqliteIndexStore(db, NullLogger<SqliteIndexStore>.Instance);
|
||||
await store.OpenAsync();
|
||||
var source = new Source
|
||||
@@ -189,7 +223,7 @@ public class AnalysisTests
|
||||
StableKey = "d",
|
||||
DisplayName = "D",
|
||||
Kind = SourceKind.NtfsLocal,
|
||||
LastRootPath = @"C:\d",
|
||||
LastRootPath = rootPath,
|
||||
Status = SourceStatus.Online
|
||||
};
|
||||
source.Id = await store.Sources.UpsertAsync(source);
|
||||
|
||||
31
tests/Explorer.Application.Tests/HostActivityLogTests.cs
Normal file
31
tests/Explorer.Application.Tests/HostActivityLogTests.cs
Normal file
@@ -0,0 +1,31 @@
|
||||
using Explorer.Application;
|
||||
using Explorer.Contracts;
|
||||
|
||||
namespace Explorer.Application.Tests;
|
||||
|
||||
public class HostActivityLogTests
|
||||
{
|
||||
[Fact]
|
||||
public void Ring_keeps_recent_events_in_order()
|
||||
{
|
||||
var log = new HostActivityLog(8);
|
||||
for (var i = 0; i < 10; i++)
|
||||
{
|
||||
log.Record("Indexing", "file-" + i);
|
||||
}
|
||||
|
||||
var recent = log.TakeRecent(5);
|
||||
Assert.Equal(5, recent.Count);
|
||||
Assert.Equal("file-5", recent[0].Message);
|
||||
Assert.Equal("file-9", recent[^1].Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Duplicate_messages_are_coalesced()
|
||||
{
|
||||
var log = new HostActivityLog();
|
||||
log.Record("Maintenance", "same");
|
||||
log.Record("Maintenance", "same");
|
||||
Assert.Single(log.TakeRecent());
|
||||
}
|
||||
}
|
||||
85
tests/Explorer.Application.Tests/IndexedPathPresenceTests.cs
Normal file
85
tests/Explorer.Application.Tests/IndexedPathPresenceTests.cs
Normal file
@@ -0,0 +1,85 @@
|
||||
using Explorer.Application;
|
||||
using Explorer.Domain;
|
||||
|
||||
namespace Explorer.Application.Tests;
|
||||
|
||||
public class IndexedPathPresenceTests
|
||||
{
|
||||
[Fact]
|
||||
public void Missing_tree_returns_the_highest_gone_folder()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "ew-idx", Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(root);
|
||||
try
|
||||
{
|
||||
Assert.Equal(
|
||||
"gone",
|
||||
IndexedPathPresence.HighestMissingPrefix(root, @"gone\deep\file.txt"));
|
||||
Assert.Equal("nope.txt", IndexedPathPresence.HighestMissingPrefix(root, "nope.txt"));
|
||||
File.WriteAllText(Path.Combine(root, "keep.txt"), "x");
|
||||
Assert.Null(IndexedPathPresence.HighestMissingPrefix(root, "keep.txt"));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(root, true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Archive_inner_path_follows_the_archive_file()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "ew-idx", Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(root);
|
||||
var zip = Path.Combine(root, "pack.zip");
|
||||
File.WriteAllBytes(zip, [1, 2, 3]);
|
||||
try
|
||||
{
|
||||
Assert.True(IndexedPathPresence.FileExists(root, @"pack.zip\inner.txt"));
|
||||
File.Delete(zip);
|
||||
Assert.False(IndexedPathPresence.FileExists(root, @"pack.zip\inner.txt"));
|
||||
Assert.Equal("pack.zip", IndexedPathPresence.HighestMissingPrefix(root, @"pack.zip\inner.txt"));
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (Directory.Exists(root))
|
||||
{
|
||||
Directory.Delete(root, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reconcile_path_uses_the_parent_for_a_missing_file()
|
||||
{
|
||||
Assert.Equal("gone", IndexedPathPresence.ReconcilePath(@"gone\deep\file.txt", "gone"));
|
||||
Assert.Equal("", IndexedPathPresence.ReconcilePath("nope.txt", "nope.txt"));
|
||||
Assert.Equal("keep", IndexedPathPresence.ReconcilePath(@"keep\a.txt", @"keep\a.txt"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Long_paths_are_detected_with_the_extended_prefix()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "ew-idx", Guid.NewGuid().ToString("N"));
|
||||
var relative = Path.Combine(new string('a', 140), new string('b', 140), "file.txt");
|
||||
var full = PathRules.Combine(root, relative);
|
||||
Directory.CreateDirectory(PathRules.ToExtended(PathRules.Parent(full)));
|
||||
File.WriteAllText(PathRules.ToExtended(full), "x");
|
||||
try
|
||||
{
|
||||
Assert.True(IndexedPathPresence.FileExists(root, relative));
|
||||
File.Delete(PathRules.ToExtended(full));
|
||||
Assert.False(IndexedPathPresence.FileExists(root, relative));
|
||||
}
|
||||
finally
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.Delete(PathRules.ToExtended(root), true);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -299,6 +299,25 @@ public class DragDropPolicyTests
|
||||
Assert.False(DragDropPolicy.IsInvalidTarget([@"C:\Docs"], @"D:\Other"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Click_jitter_does_not_start_a_drag()
|
||||
{
|
||||
Assert.False(DragDropPolicy.ExceedsDistance(4, 3, DragDropPolicy.DragStartDistance));
|
||||
Assert.False(DragDropPolicy.ExceedsDistance(12, 8, DragDropPolicy.DragStartDistance));
|
||||
Assert.True(DragDropPolicy.ExceedsDistance(16, 0, DragDropPolicy.DragStartDistance));
|
||||
Assert.True(DragDropPolicy.ExceedsDistance(12, 12, DragDropPolicy.DragStartDistance));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Same_folder_drop_is_redundant()
|
||||
{
|
||||
Assert.True(DragDropPolicy.AllAlreadyInDirectory([@"C:\Docs\a.txt", @"C:\Docs\b.txt"], @"C:\Docs"));
|
||||
Assert.True(DragDropPolicy.AllAlreadyInDirectory([@"C:\Docs\a.txt"], @"C:\Docs\"));
|
||||
Assert.False(DragDropPolicy.AllAlreadyInDirectory([@"C:\Docs\a.txt"], @"C:\Other"));
|
||||
Assert.False(DragDropPolicy.AllAlreadyInDirectory([@"C:\Docs\a.txt", @"C:\Other\b.txt"], @"C:\Docs"));
|
||||
Assert.False(DragDropPolicy.AllAlreadyInDirectory([], @"C:\Docs"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Same_volume_uses_drive_or_unc_share()
|
||||
{
|
||||
|
||||
@@ -120,6 +120,21 @@ public class BackgroundMaintenanceCoordinatorTests
|
||||
Assert.Equal(new[] { 3L }, indexing.IdleVerifies);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Run_now_keeps_running_while_user_stays_active()
|
||||
{
|
||||
var hash = new FakeHash();
|
||||
var indexing = new FakeIndexing();
|
||||
var coordinator = Create(hash, indexing, idle: TimeSpan.Zero, sources: [StaleLocal()]);
|
||||
coordinator.RunNow();
|
||||
await coordinator.TickAsync(CancellationToken.None);
|
||||
Assert.Contains("checking", coordinator.Snapshot.Message, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
await coordinator.TickAsync(CancellationToken.None);
|
||||
Assert.True(indexing.IdleAllowed);
|
||||
Assert.DoesNotContain("Paused because user is active", coordinator.Snapshot.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Idle_verifies_a_fresh_online_source()
|
||||
{
|
||||
@@ -220,10 +235,12 @@ public class BackgroundMaintenanceCoordinatorTests
|
||||
{
|
||||
public bool Paused { get; set; } = true;
|
||||
public bool IsPaused => Paused;
|
||||
public string? CurrentPath => null;
|
||||
public void Pause() => Paused = true;
|
||||
public void Resume() => Paused = false;
|
||||
public void BeginUserRequested() { }
|
||||
public Task<bool> HasPendingAsync(CancellationToken cancellationToken = default) => Task.FromResult(false);
|
||||
public Task<long> CountPendingAsync(CancellationToken cancellationToken = default) => Task.FromResult(0L);
|
||||
}
|
||||
|
||||
private sealed class FakeHistory : IHistoryMaintenance
|
||||
@@ -303,6 +320,13 @@ public class BackgroundMaintenanceCoordinatorTests
|
||||
public Task<IReadOnlyList<DuplicateGroup>> GetDuplicateGroupsAsync(
|
||||
long? sourceId, string? pathPrefix, int take, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<IReadOnlyList<DuplicateGroup>>([]);
|
||||
public Task<IReadOnlyList<byte[]>> GetDuplicateHashesAsync(
|
||||
long? sourceId, string? pathPrefix, int take, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<IReadOnlyList<byte[]>>([]);
|
||||
public Task<IReadOnlyList<DuplicateGroup>> GetDuplicateGroupsByHashesAsync(
|
||||
IReadOnlyList<byte[]> hashes, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<IReadOnlyList<DuplicateGroup>>([]);
|
||||
public Task<long> CountPendingAsync(CancellationToken cancellationToken = default) => Task.FromResult(0L);
|
||||
}
|
||||
|
||||
private sealed class TempEnv : IAppEnvironment
|
||||
|
||||
@@ -327,6 +327,30 @@ public class ScannerTests
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Folder_reconcile_tombs_removed_directory_tree()
|
||||
{
|
||||
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);
|
||||
Directory.Delete(Path.Combine(root, "Movies"), recursive: true);
|
||||
var reconciler = new FolderReconciler(store, new IoEnumerator(), new StorageProviderRegistry([], NullLogger<StorageProviderRegistry>.Instance));
|
||||
Assert.True(await reconciler.ReconcileAsync(source, "Movies", CancellationToken.None));
|
||||
var folder = await store.Entries.GetByPathAsync(source.Id, "Movies");
|
||||
var child = await store.Entries.GetByPathAsync(source.Id, @"Movies\b.txt");
|
||||
Assert.Equal(EntryStatus.Deleted, folder!.Status);
|
||||
Assert.Equal(EntryStatus.Deleted, child!.Status);
|
||||
}
|
||||
finally
|
||||
{
|
||||
TryDelete(root);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Folder_reconcile_tombs_removed_file()
|
||||
{
|
||||
|
||||
51
tests/Explorer.Storage.Tests/SqliteDatabaseSessionTests.cs
Normal file
51
tests/Explorer.Storage.Tests/SqliteDatabaseSessionTests.cs
Normal file
@@ -0,0 +1,51 @@
|
||||
using Explorer.Application;
|
||||
using Explorer.Storage.Sqlite;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace Explorer.Storage.Tests;
|
||||
|
||||
public class SqliteDatabaseSessionTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Can_browse_and_edit_a_standalone_database()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "ew-sql", Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var path = Path.Combine(dir, "sample.db");
|
||||
var indexPath = Path.Combine(dir, "index.db");
|
||||
await using var writer = new SqliteIndexStore(indexPath, NullLogger<SqliteIndexStore>.Instance);
|
||||
await writer.OpenAsync();
|
||||
await writer.CloseAsync();
|
||||
|
||||
await using var session = new SqliteDatabaseSession(indexPath);
|
||||
await session.OpenAsync(path, preferWrite: true);
|
||||
Assert.True(session.CanWrite);
|
||||
await session.ExecuteAsync("CREATE TABLE notes (id INTEGER PRIMARY KEY, body TEXT);");
|
||||
await session.InsertRowAsync("notes", new Dictionary<string, object?> { ["body"] = "hello" });
|
||||
var page = await session.ReadTableAsync("notes", 0, 50);
|
||||
Assert.Contains("body", page.Columns);
|
||||
Assert.Single(page.Rows);
|
||||
var rowId = Convert.ToInt64(page.Rows[0][0]);
|
||||
await session.UpdateCellAsync("notes", rowId, "body", "world");
|
||||
var query = await session.ExecuteAsync("SELECT body FROM notes");
|
||||
Assert.Equal("world", query.Rows[0][0]?.ToString());
|
||||
await session.DeleteRowAsync("notes", rowId);
|
||||
Assert.Equal(0, (await session.ReadTableAsync("notes", 0, 10)).TotalRows);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Index_opens_read_only_while_writer_holds_lock()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "ew-sql", Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var path = Path.Combine(dir, "index.db");
|
||||
await using var writer = new SqliteIndexStore(path, NullLogger<SqliteIndexStore>.Instance);
|
||||
await writer.OpenAsync();
|
||||
await using var session = new SqliteDatabaseSession(path);
|
||||
await session.OpenAsync(path, preferWrite: true);
|
||||
Assert.False(session.CanWrite);
|
||||
Assert.Contains("Read-only", session.ModeLabel, StringComparison.OrdinalIgnoreCase);
|
||||
var tables = await session.ListTablesAsync();
|
||||
Assert.Contains("sources", tables);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user