Show folder names immediately and refresh stale index sizes without walking the whole drive.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -128,6 +128,7 @@ public class AnalysisTests
|
||||
store,
|
||||
new HydrationGuard(new StorageProviderRegistry([], NullLogger<StorageProviderRegistry>.Instance)),
|
||||
NullLogger<DuplicateHashWorker>.Instance);
|
||||
worker.Resume();
|
||||
await worker.ProcessPendingAsync(CancellationToken.None);
|
||||
|
||||
var skipped = await store.Entries.GetByPathAsync(source.Id, "online-only.bin");
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
using Explorer.Application;
|
||||
using Explorer.Domain;
|
||||
|
||||
namespace Explorer.Application.Tests;
|
||||
|
||||
public class BackgroundMaintenancePlannerTests
|
||||
{
|
||||
[Fact]
|
||||
public void Skips_network_cloud_and_removable()
|
||||
{
|
||||
var utc = DateTimeOffset.Parse("2026-08-26T00:00:00Z");
|
||||
var chosen = BackgroundMaintenancePlanner.NextLocalScan(
|
||||
[
|
||||
Source("net", SourceKind.Smb, SourceStatus.Stale, utc.AddDays(-30)),
|
||||
Source("cloud", SourceKind.Cloud, SourceStatus.Stale, utc.AddDays(-30)),
|
||||
Source("usb", SourceKind.Removable, SourceStatus.Stale, utc.AddDays(-30)),
|
||||
Source("local", SourceKind.NtfsLocal, SourceStatus.Stale, utc.AddDays(-30))
|
||||
], utc);
|
||||
Assert.NotNull(chosen);
|
||||
Assert.Equal("local", chosen.StableKey);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Prefers_stale_over_old_but_online()
|
||||
{
|
||||
var utc = DateTimeOffset.Parse("2026-08-26T00:00:00Z");
|
||||
var chosen = BackgroundMaintenancePlanner.NextLocalScan(
|
||||
[
|
||||
Source("old", SourceKind.NtfsLocal, SourceStatus.Online, utc.AddDays(-40)),
|
||||
Source("stale", SourceKind.NtfsLocal, SourceStatus.Stale, utc.AddDays(-2))
|
||||
], utc);
|
||||
Assert.Equal("stale", chosen!.StableKey);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Fresh_online_local_is_not_full_rescanned()
|
||||
{
|
||||
var utc = DateTimeOffset.Parse("2026-08-26T00:00:00Z");
|
||||
var chosen = BackgroundMaintenancePlanner.NextLocalScan(
|
||||
[
|
||||
Source("fresh", SourceKind.NtfsLocal, SourceStatus.Online, utc.AddHours(-2))
|
||||
], utc);
|
||||
Assert.Null(chosen);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Fresh_online_local_is_verified_when_idle()
|
||||
{
|
||||
var utc = DateTimeOffset.Parse("2026-08-26T00:00:00Z");
|
||||
var chosen = BackgroundMaintenancePlanner.NextLocalVerify(
|
||||
[
|
||||
Source("fresh", SourceKind.NtfsLocal, SourceStatus.Online, utc.AddHours(-2))
|
||||
]);
|
||||
Assert.NotNull(chosen);
|
||||
Assert.Equal("fresh", chosen.StableKey);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Does_not_pick_a_source_already_queued()
|
||||
{
|
||||
var utc = DateTimeOffset.Parse("2026-08-26T00:00:00Z");
|
||||
var source = Source("local", SourceKind.NtfsLocal, SourceStatus.Stale, utc.AddDays(-30));
|
||||
source.Id = 7;
|
||||
var chosen = BackgroundMaintenancePlanner.NextLocalScan([source], utc, alreadyQueued: new HashSet<long> { 7 });
|
||||
Assert.Null(chosen);
|
||||
}
|
||||
|
||||
private static Source Source(string key, SourceKind kind, SourceStatus status, DateTimeOffset indexed)
|
||||
=> new()
|
||||
{
|
||||
StableKey = key,
|
||||
DisplayName = key,
|
||||
Kind = kind,
|
||||
Status = status,
|
||||
LastRootPath = @"D:\",
|
||||
LastIndexedUtc = indexed
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
using Explorer.Application;
|
||||
|
||||
namespace Explorer.Application.Tests;
|
||||
|
||||
public class BackgroundWorkPolicyTests
|
||||
{
|
||||
private static BackgroundWorkInputs Idle(TimeSpan? idle = null, bool enabled = true, bool ac = true,
|
||||
bool acOnly = true, bool foreground = false, bool runNow = false)
|
||||
=> new(
|
||||
enabled,
|
||||
TimeSpan.FromMinutes(10),
|
||||
idle ?? TimeSpan.FromMinutes(12),
|
||||
acOnly,
|
||||
ac,
|
||||
foreground,
|
||||
runNow);
|
||||
|
||||
[Fact]
|
||||
public void Below_threshold_stays_active()
|
||||
{
|
||||
var decision = BackgroundWorkPolicy.Evaluate(Idle(TimeSpan.FromMinutes(3)));
|
||||
Assert.Equal(UserActivityState.Active, decision.Activity);
|
||||
Assert.False(decision.MaintenanceAllowed);
|
||||
Assert.Equal(MaintenanceSkipReason.BelowIdleThreshold, decision.SkipReason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Idle_past_threshold_allows_maintenance()
|
||||
{
|
||||
var decision = BackgroundWorkPolicy.Evaluate(Idle());
|
||||
Assert.Equal(UserActivityState.Idle, decision.Activity);
|
||||
Assert.True(decision.MaintenanceAllowed);
|
||||
Assert.Equal(MaintenanceSkipReason.None, decision.SkipReason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Activity_resumes_and_blocks_maintenance()
|
||||
{
|
||||
var decision = BackgroundWorkPolicy.Evaluate(Idle(TimeSpan.Zero));
|
||||
Assert.Equal(UserActivityState.Active, decision.Activity);
|
||||
Assert.False(decision.MaintenanceAllowed);
|
||||
Assert.Equal(MaintenanceSkipReason.UserActive, decision.SkipReason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Foreground_jobs_block_even_when_idle()
|
||||
{
|
||||
var decision = BackgroundWorkPolicy.Evaluate(Idle(foreground: true));
|
||||
Assert.False(decision.MaintenanceAllowed);
|
||||
Assert.Equal(MaintenanceSkipReason.ForegroundOperations, decision.SkipReason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Disabled_policy_never_allows()
|
||||
{
|
||||
var decision = BackgroundWorkPolicy.Evaluate(Idle(enabled: false));
|
||||
Assert.False(decision.MaintenanceAllowed);
|
||||
Assert.Equal(MaintenanceSkipReason.Disabled, decision.SkipReason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ac_only_skips_on_battery()
|
||||
{
|
||||
var decision = BackgroundWorkPolicy.Evaluate(Idle(ac: false, acOnly: true));
|
||||
Assert.Equal(UserActivityState.Idle, decision.Activity);
|
||||
Assert.False(decision.MaintenanceAllowed);
|
||||
Assert.Equal(MaintenanceSkipReason.OnBattery, decision.SkipReason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ac_only_allows_on_ac()
|
||||
{
|
||||
var decision = BackgroundWorkPolicy.Evaluate(Idle(ac: true, acOnly: true));
|
||||
Assert.True(decision.MaintenanceAllowed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Run_now_bypasses_idle_and_battery_but_not_foreground()
|
||||
{
|
||||
var allowed = BackgroundWorkPolicy.Evaluate(Idle(TimeSpan.Zero, ac: false, runNow: true));
|
||||
Assert.True(allowed.MaintenanceAllowed);
|
||||
|
||||
var blocked = BackgroundWorkPolicy.Evaluate(Idle(TimeSpan.Zero, foreground: true, runNow: true));
|
||||
Assert.False(blocked.MaintenanceAllowed);
|
||||
Assert.Equal(MaintenanceSkipReason.ForegroundOperations, blocked.SkipReason);
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,8 @@ public class BrowseHydrationTests
|
||||
Assert.Equal(BrowseHydration.ConstrainedProviderBatch, BrowseHydration.ProviderBatchSize(SourceKind.NtfsLocal, true));
|
||||
Assert.Equal(BrowseHydration.ConstrainedWorkers, BrowseHydration.WorkerCount(SourceKind.Smb, false));
|
||||
Assert.Equal(BrowseHydration.LocalWorkers, BrowseHydration.WorkerCount(SourceKind.NtfsLocal, false));
|
||||
Assert.Equal(1, BrowseHydration.FirstPublish);
|
||||
Assert.True(BrowseHydration.FirstPublish < BrowseHydration.PublishBatch);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -88,6 +90,102 @@ public class BrowseHydrationTests
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Progressive_listing_publishes_first_names_before_enumeration_finishes()
|
||||
{
|
||||
var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var (browse, store, _) = await CreateAsync(enumerator: new GatedEnumerator(gate));
|
||||
try
|
||||
{
|
||||
await using var it = browse.ListProgressiveAsync(@"C:\").GetAsyncEnumerator();
|
||||
var first = it.MoveNextAsync().AsTask();
|
||||
var winner = await Task.WhenAny(first, Task.Delay(TimeSpan.FromSeconds(2))).ConfigureAwait(true);
|
||||
Assert.Same(first, winner);
|
||||
Assert.True(await first);
|
||||
Assert.Contains(it.Current.Added, i => i.Name == "first");
|
||||
Assert.False(it.Current.EnumerationComplete);
|
||||
Assert.False(gate.Task.IsCompleted);
|
||||
|
||||
gate.SetResult();
|
||||
var sawSecond = it.Current.Added.Any(i => i.Name == "second");
|
||||
var complete = it.Current.EnumerationComplete;
|
||||
while (!complete && await it.MoveNextAsync())
|
||||
{
|
||||
sawSecond |= it.Current.Added.Any(i => i.Name == "second");
|
||||
complete = it.Current.EnumerationComplete;
|
||||
}
|
||||
|
||||
Assert.True(sawSecond);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await store.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Progressive_listing_applies_index_sizes_after_names()
|
||||
{
|
||||
var (browse, store, _) = await CreateAsync();
|
||||
try
|
||||
{
|
||||
var source = (await store.Sources.GetAllAsync()).Single();
|
||||
source.LastIndexedUtc = DateTimeOffset.UtcNow;
|
||||
await store.Sources.UpsertAsync(source);
|
||||
var root = new IndexEntry
|
||||
{
|
||||
SourceId = source.Id,
|
||||
Name = "C:",
|
||||
NameNorm = "c:",
|
||||
IsDirectory = true,
|
||||
PathRel = "",
|
||||
LastSeenUtc = DateTimeOffset.UtcNow
|
||||
};
|
||||
root.Id = await store.Entries.UpsertAsync(root);
|
||||
await store.Entries.UpsertAsync(new IndexEntry
|
||||
{
|
||||
SourceId = source.Id,
|
||||
ParentId = root.Id,
|
||||
Name = "Movies",
|
||||
NameNorm = "movies",
|
||||
IsDirectory = true,
|
||||
PathRel = "Movies",
|
||||
LastSeenUtc = DateTimeOffset.UtcNow,
|
||||
AggregateSize = 200
|
||||
});
|
||||
|
||||
await using var it = browse.ListProgressiveAsync(@"C:\").GetAsyncEnumerator();
|
||||
Assert.True(await it.MoveNextAsync());
|
||||
Assert.NotEmpty(it.Current.Added);
|
||||
Assert.All(it.Current.Added, item => Assert.Equal(0, (int)(item.Hydration & ItemHydrationFlags.Index)));
|
||||
var movies = it.Current.Added.FirstOrDefault(i => i.Name == "Movies");
|
||||
if (movies is not null)
|
||||
{
|
||||
Assert.Equal(0, movies.SizeBytes);
|
||||
}
|
||||
|
||||
long? size = movies?.SizeBytes;
|
||||
var complete = it.Current.HydrationComplete;
|
||||
while (!complete && await it.MoveNextAsync())
|
||||
{
|
||||
movies ??= it.Current.Added.FirstOrDefault(i => i.Name == "Movies");
|
||||
var updated = it.Current.Updated.FirstOrDefault(i => i.Name == "Movies");
|
||||
if (updated is not null)
|
||||
{
|
||||
size = updated.SizeBytes;
|
||||
}
|
||||
|
||||
complete = it.Current.HydrationComplete;
|
||||
}
|
||||
|
||||
Assert.Equal(200, size);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await store.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Progressive_listing_cancels_without_applying_stale_provider_results()
|
||||
{
|
||||
@@ -235,6 +333,46 @@ public class BrowseHydrationTests
|
||||
}
|
||||
}
|
||||
|
||||
file sealed class GatedEnumerator(TaskCompletionSource gate) : IFileSystemEnumerator
|
||||
{
|
||||
public IEnumerable<FileSystemItem> EnumerateChildren(string directoryPath)
|
||||
=> EnumerateChildrenSafe(directoryPath, out _);
|
||||
|
||||
public FileSystemItem? GetItem(string path)
|
||||
=> throw new InvalidOperationException("GetItem must not run during folder listing.");
|
||||
|
||||
public IReadOnlyList<FileSystemItem> EnumerateChildrenSafe(string directoryPath, out string? error)
|
||||
{
|
||||
error = null;
|
||||
return
|
||||
[
|
||||
new FileSystemItem { FullPath = Path.Combine(directoryPath, "first"), Name = "first", IsDirectory = true },
|
||||
new FileSystemItem { FullPath = Path.Combine(directoryPath, "second"), Name = "second", IsDirectory = true }
|
||||
];
|
||||
}
|
||||
|
||||
public IEnumerable<FileSystemItem> EnumerateChildrenStreaming(
|
||||
string directoryPath,
|
||||
FileEnumerationSink sink,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
yield return new FileSystemItem
|
||||
{
|
||||
FullPath = Path.Combine(directoryPath, "first"),
|
||||
Name = "first",
|
||||
IsDirectory = true
|
||||
};
|
||||
gate.Task.GetAwaiter().GetResult();
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
yield return new FileSystemItem
|
||||
{
|
||||
FullPath = Path.Combine(directoryPath, "second"),
|
||||
Name = "second",
|
||||
IsDirectory = true
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
file sealed class GuardedEnumerator : IFileSystemEnumerator
|
||||
{
|
||||
public int GetItemCalls { get; private set; }
|
||||
|
||||
@@ -37,6 +37,47 @@ public class BrowseServiceTests
|
||||
Assert.Contains(listing.Items, i => i.FullPath == LocationRoots.RecycleBin);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Home_lists_existing_known_folders()
|
||||
{
|
||||
var (browse, store) = await CreateAsync(
|
||||
@"C:\",
|
||||
"C:",
|
||||
knownFolders: new StubKnownFolders(
|
||||
[
|
||||
new KnownUserFolder("Documents", @"C:\Users\Test\Documents", "d"),
|
||||
new KnownUserFolder("Downloads", @"C:\Users\Test\Downloads", "l")
|
||||
]));
|
||||
var listing = browse.ListHome();
|
||||
Assert.Equal(LocationRoots.Home, listing.Path);
|
||||
Assert.Equal(2, listing.Items.Count);
|
||||
Assert.Equal("Documents", listing.Items[0].DisplayName);
|
||||
Assert.Equal(@"C:\Users\Test\Documents", listing.Items[0].FullPath);
|
||||
Assert.True(listing.Items[0].IsDirectory);
|
||||
await store.DisposeAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Favorites_lists_pinned_folders_including_offline()
|
||||
{
|
||||
var missing = Path.Combine(Path.GetTempPath(), "ew-fav-missing", Guid.NewGuid().ToString("N"));
|
||||
var (browse, store) = await CreateAsync(
|
||||
@"C:\",
|
||||
"C:",
|
||||
configurePrefs: prefs => prefs.Save(UiPreferences.Default with
|
||||
{
|
||||
FavoriteFolders = [@"D:\Photos", missing]
|
||||
}));
|
||||
var listing = browse.ListFavorites();
|
||||
Assert.Equal(LocationRoots.Favorites, listing.Path);
|
||||
Assert.Equal(2, listing.Items.Count);
|
||||
Assert.Equal(@"D:\Photos", listing.Items[0].FullPath);
|
||||
Assert.Equal("Photos", listing.Items[0].Name);
|
||||
Assert.Equal(missing, listing.Items[1].FullPath);
|
||||
Assert.Contains("(Offline)", listing.Items[1].DisplayName);
|
||||
await store.DisposeAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task This_pc_lists_untracked_network_drives_as_importable()
|
||||
{
|
||||
@@ -211,7 +252,8 @@ public class BrowseServiceTests
|
||||
string display,
|
||||
IRecycleBinCatalog? recycle = null,
|
||||
IFileSystemEnumerator? enumerator = null,
|
||||
Action<UiPreferencesStore>? configurePrefs = null)
|
||||
Action<UiPreferencesStore>? configurePrefs = null,
|
||||
IKnownUserFolderCatalog? knownFolders = null)
|
||||
{
|
||||
var db = Path.Combine(Path.GetTempPath(), "ew-browse", Guid.NewGuid().ToString("N"), "index.db");
|
||||
var store = new SqliteIndexStore(db, NullLogger<SqliteIndexStore>.Instance);
|
||||
@@ -244,11 +286,17 @@ public class BrowseServiceTests
|
||||
new StorageProviderRegistry([], NullLogger<StorageProviderRegistry>.Instance),
|
||||
new CloudPlaceStore(env),
|
||||
prefs,
|
||||
recycle: recycle);
|
||||
recycle: recycle,
|
||||
knownFolders: knownFolders);
|
||||
return (browse, store);
|
||||
}
|
||||
}
|
||||
|
||||
file sealed class StubKnownFolders(IReadOnlyList<KnownUserFolder> folders) : IKnownUserFolderCatalog
|
||||
{
|
||||
public IReadOnlyList<KnownUserFolder> ListExisting() => folders;
|
||||
}
|
||||
|
||||
file sealed class BrowseEnumerator : IFileSystemEnumerator
|
||||
{
|
||||
public IEnumerable<FileSystemItem> EnumerateChildren(string directoryPath)
|
||||
|
||||
49
tests/Explorer.Application.Tests/FavoriteFoldersTests.cs
Normal file
49
tests/Explorer.Application.Tests/FavoriteFoldersTests.cs
Normal file
@@ -0,0 +1,49 @@
|
||||
using Explorer.Application;
|
||||
using Explorer.Domain;
|
||||
|
||||
namespace Explorer.Application.Tests;
|
||||
|
||||
public class FavoriteFoldersTests
|
||||
{
|
||||
[Fact]
|
||||
public void Normalize_dedupes_and_skips_virtual_roots()
|
||||
{
|
||||
var normalized = FavoriteFolders.Normalize(
|
||||
[
|
||||
@"D:\Photos\",
|
||||
@"d:\photos",
|
||||
LocationRoots.ThisPc,
|
||||
LocationRoots.Favorites,
|
||||
@"C:\Users\Dominique\Documents"
|
||||
]);
|
||||
Assert.Equal([@"D:\Photos", @"C:\Users\Dominique\Documents"], normalized);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Contains_is_case_insensitive()
|
||||
{
|
||||
var pinned = FavoriteFolders.Normalize([@"D:\Photos"]);
|
||||
Assert.True(FavoriteFolders.Contains(pinned, @"d:\photos\"));
|
||||
Assert.False(FavoriteFolders.Contains(pinned, @"D:\Other"));
|
||||
Assert.False(FavoriteFolders.Contains(pinned, LocationRoots.Home));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Add_appends_new_folders_up_to_max()
|
||||
{
|
||||
var current = Enumerable.Range(0, FavoriteFolders.MaxCount - 1).Select(i => $@"D:\F{i}");
|
||||
var next = FavoriteFolders.Add(current, [@"D:\New", @"D:\F0", @"E:\TooMany"]);
|
||||
Assert.Equal(FavoriteFolders.MaxCount, next.Count);
|
||||
Assert.Equal(@"D:\New", next[^1]);
|
||||
Assert.DoesNotContain(@"E:\TooMany", next);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Remove_unpins_without_touching_other_entries()
|
||||
{
|
||||
var next = FavoriteFolders.Remove(
|
||||
[@"D:\Photos", @"D:\Music"],
|
||||
[@"d:\photos", LocationRoots.ThisPc]);
|
||||
Assert.Equal([@"D:\Music"], next);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
using Explorer.Application;
|
||||
using Explorer.Domain;
|
||||
|
||||
namespace Explorer.Application.Tests;
|
||||
|
||||
public class FolderDisplayRefreshTests
|
||||
{
|
||||
[Fact]
|
||||
public void Pick_prefers_visible_then_largest()
|
||||
{
|
||||
var items = new[]
|
||||
{
|
||||
Dir(@"C:\a", "a", 50_000_000),
|
||||
Dir(@"C:\b", "b", 90_000_000),
|
||||
Dir(@"C:\c", "c", 5_000),
|
||||
Dir(@"C:\d", "d", 20_000_000)
|
||||
};
|
||||
var picked = FolderDisplayRefresh.Pick(items, [@"C:\d", @"C:\a"]);
|
||||
Assert.Equal(3, picked.Count);
|
||||
Assert.Equal(@"C:\a", picked[0].FullPath);
|
||||
Assert.Equal(@"C:\d", picked[1].FullPath);
|
||||
Assert.Equal(@"C:\b", picked[2].FullPath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Pick_caps_and_skips_tiny_or_cloud()
|
||||
{
|
||||
var items = Enumerable.Range(0, 12)
|
||||
.Select(i => Dir($@"C:\f{i}", $"f{i}", (13 - i) * 2_000_000L))
|
||||
.Append(Dir(@"C:\tiny", "tiny", 100))
|
||||
.Append(Dir(@"C:\cloud", "cloud", 80_000_000, hydrate: true))
|
||||
.ToList();
|
||||
var picked = FolderDisplayRefresh.Pick(items);
|
||||
Assert.Equal(FolderDisplayRefresh.MaxProbes, picked.Count);
|
||||
Assert.Equal(@"C:\f0", picked[0].FullPath);
|
||||
Assert.DoesNotContain(picked, item => item.Name is "tiny" or "cloud");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NeedsVerify_compares_child_counts()
|
||||
{
|
||||
Assert.True(FolderDisplayRefresh.NeedsVerify(10, 2000));
|
||||
Assert.False(FolderDisplayRefresh.NeedsVerify(10, 10));
|
||||
Assert.False(FolderDisplayRefresh.NeedsVerify(-1, 2000));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasIndexCounts_requires_index_hydration()
|
||||
{
|
||||
var live = Dir(@"C:\a", "a", 50_000_000, hydration: ItemHydrationFlags.Shell | ItemHydrationFlags.Metadata);
|
||||
Assert.False(FolderDisplayRefresh.HasIndexCounts(live));
|
||||
Assert.True(FolderDisplayRefresh.HasIndexCounts(live.Overlay(hydration: ItemHydrationFlags.Index)));
|
||||
}
|
||||
|
||||
private static FileSystemItem Dir(string path, string name, long size, bool hydrate = false, ItemHydrationFlags? hydration = null)
|
||||
=> new()
|
||||
{
|
||||
FullPath = path,
|
||||
Name = name,
|
||||
IsDirectory = true,
|
||||
SizeBytes = size,
|
||||
IndexedChildCount = 4,
|
||||
Hydration = hydration ?? ItemHydrationFlags.All,
|
||||
Cloud = hydrate
|
||||
? new CloudPresence("x", CloudAvailability.OnlineOnly, size, null, true)
|
||||
: null
|
||||
};
|
||||
}
|
||||
85
tests/Explorer.Application.Tests/FolderStatusTextTests.cs
Normal file
85
tests/Explorer.Application.Tests/FolderStatusTextTests.cs
Normal file
@@ -0,0 +1,85 @@
|
||||
using Explorer.Application;
|
||||
using Explorer.Domain;
|
||||
|
||||
namespace Explorer.Application.Tests;
|
||||
|
||||
public class FolderStatusTextTests
|
||||
{
|
||||
[Fact]
|
||||
public void Empty_folder_is_zero_items()
|
||||
{
|
||||
Assert.Equal("0 items", FolderStatusText.Format([], []));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Folder_totals_use_known_sizes()
|
||||
{
|
||||
var items = new[]
|
||||
{
|
||||
File("a.txt", 100),
|
||||
File("b.txt", 924),
|
||||
Folder("pics", size: 0, SizeKnowledge.Unknown)
|
||||
};
|
||||
|
||||
Assert.Equal("3 items · 1 KB", FolderStatusText.Format(items, []));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Selection_replaces_folder_totals()
|
||||
{
|
||||
var items = new[] { File("a.txt", 100), File("b.txt", 200), File("c.txt", 300) };
|
||||
|
||||
Assert.Equal("1 item selected · 200 B", FolderStatusText.Format(items, [items[1]]));
|
||||
Assert.Equal("2 items selected · 300 B", FolderStatusText.Format(items, [items[0], items[1]]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unknown_folder_sizes_are_omitted()
|
||||
{
|
||||
var folders = new[]
|
||||
{
|
||||
Folder("a", 0, SizeKnowledge.Unknown),
|
||||
Folder("b", 0, SizeKnowledge.Unknown)
|
||||
};
|
||||
|
||||
Assert.Equal("2 items", FolderStatusText.Format(folders, []));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Indexed_folder_sizes_are_included()
|
||||
{
|
||||
var items = new[]
|
||||
{
|
||||
Folder("docs", 2_048, SizeKnowledge.Calculated),
|
||||
File("readme.txt", 1_024)
|
||||
};
|
||||
|
||||
Assert.Equal("2 items · 3 KB", FolderStatusText.Format(items, []));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Zero_byte_file_selection_still_shows_size()
|
||||
{
|
||||
var file = File("empty.dat", 0);
|
||||
Assert.Equal("1 item selected · 0 B", FolderStatusText.Format([file], [file]));
|
||||
}
|
||||
|
||||
private static FileSystemItem File(string name, long size)
|
||||
=> new()
|
||||
{
|
||||
FullPath = @"C:\" + name,
|
||||
Name = name,
|
||||
SizeBytes = size,
|
||||
SizeKnowledge = SizeKnowledge.Calculated
|
||||
};
|
||||
|
||||
private static FileSystemItem Folder(string name, long size, SizeKnowledge knowledge)
|
||||
=> new()
|
||||
{
|
||||
FullPath = @"C:\" + name,
|
||||
Name = name,
|
||||
IsDirectory = true,
|
||||
SizeBytes = size,
|
||||
SizeKnowledge = knowledge
|
||||
};
|
||||
}
|
||||
29
tests/Explorer.Application.Tests/MarqueeRangeTests.cs
Normal file
29
tests/Explorer.Application.Tests/MarqueeRangeTests.cs
Normal file
@@ -0,0 +1,29 @@
|
||||
using Explorer.Application;
|
||||
|
||||
namespace Explorer.Application.Tests;
|
||||
|
||||
public class MarqueeRangeTests
|
||||
{
|
||||
[Fact]
|
||||
public void Stack_selects_rows_the_band_touches()
|
||||
{
|
||||
Assert.Equal([0, 1, 2], MarqueeRange.Stack(10, 70, 10, 24));
|
||||
Assert.Equal([1], MarqueeRange.Stack(24, 30, 10, 24));
|
||||
Assert.Equal([0], MarqueeRange.Stack(10, 10.2, 10, 24));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Wrap_selects_tiles_the_band_covers()
|
||||
{
|
||||
// 3 columns, 40x40 cells. Band over the first two tiles of row 0 and the first of row 1.
|
||||
var hits = MarqueeRange.Wrap(5, 5, 55, 45, count: 8, columns: 3, itemWidth: 40, itemHeight: 40);
|
||||
Assert.Equal([0, 1, 3, 4], hits);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Wrap_ignores_cells_past_the_item_count()
|
||||
{
|
||||
var hits = MarqueeRange.Wrap(0, 0, 120, 40, count: 2, columns: 3, itemWidth: 40, itemHeight: 40);
|
||||
Assert.Equal([0, 1], hits);
|
||||
}
|
||||
}
|
||||
@@ -291,6 +291,43 @@ public class SourceManagerTests
|
||||
await mgr.RefreshOnlineStateAsync(forceRefresh: true);
|
||||
Assert.Equal(afterInit + 1, volumes.EnumerateCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reachable_unc_share_not_listed_as_a_drive_becomes_online()
|
||||
{
|
||||
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();
|
||||
var env = new FakeEnv(Path.GetDirectoryName(db)!);
|
||||
var mgr = new SourceManager(store, volumes, env, new SystemClock(), NullLogger<SourceManager>.Instance);
|
||||
await mgr.InitializeAsync();
|
||||
var source = await mgr.AddUncAsync(@"\\nas\media");
|
||||
Assert.Equal(SourceStatus.Offline, source.Status);
|
||||
|
||||
volumes.ReachablePaths.Add(@"\\nas\media");
|
||||
await mgr.RefreshOnlineStateAsync(forceRefresh: true);
|
||||
var again = Assert.Single(await store.Sources.GetAllAsync());
|
||||
Assert.Equal(SourceStatus.Online, again.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Mark_reachable_flips_offline_unc_immediately()
|
||||
{
|
||||
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();
|
||||
var env = new FakeEnv(Path.GetDirectoryName(db)!);
|
||||
var mgr = new SourceManager(store, volumes, env, new SystemClock(), NullLogger<SourceManager>.Instance);
|
||||
await mgr.InitializeAsync();
|
||||
await mgr.AddUncAsync(@"\\nas\media");
|
||||
|
||||
var updated = await mgr.MarkReachableAsync(@"\\nas\media\clip.mkv");
|
||||
Assert.NotNull(updated);
|
||||
Assert.Equal(SourceStatus.Online, updated.Status);
|
||||
Assert.Equal(SourceStatus.Online, (await store.Sources.GetAllAsync()).Single().Status);
|
||||
}
|
||||
}
|
||||
|
||||
file sealed class FakeVolumes : IVolumeService
|
||||
@@ -310,12 +347,19 @@ file sealed class FakeVolumes : IVolumeService
|
||||
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));
|
||||
public bool IsPathReachable(string path)
|
||||
=> Matches(Online.Select(v => v.RootPath), path) || Matches(ReachablePaths, path);
|
||||
|
||||
public List<string> ReachablePaths { get; } = [];
|
||||
|
||||
public VolumeSpace GetSpace(string path)
|
||||
{
|
||||
var fp = Probe(path);
|
||||
return new VolumeSpace(fp?.CapacityBytes, fp?.FreeBytes);
|
||||
}
|
||||
|
||||
private static bool Matches(IEnumerable<string> roots, string path)
|
||||
=> roots.Any(root => path.StartsWith(root.TrimEnd('\\'), StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
file sealed class FakeEnv : IAppEnvironment
|
||||
|
||||
77
tests/Explorer.Application.Tests/TreeRevealSelectorTests.cs
Normal file
77
tests/Explorer.Application.Tests/TreeRevealSelectorTests.cs
Normal file
@@ -0,0 +1,77 @@
|
||||
using Explorer.Application;
|
||||
|
||||
namespace Explorer.Application.Tests;
|
||||
|
||||
public class TreeRevealSelectorTests
|
||||
{
|
||||
private static readonly TreeRevealCandidate Drive = new(@"D:\", false);
|
||||
private static readonly TreeRevealCandidate Photos = new(@"D:\Photos", true);
|
||||
private static readonly TreeRevealCandidate HomeDocs = new(@"C:\Users\Dominique\Documents", false);
|
||||
private static readonly TreeRevealCandidate FavDocs = new(@"C:\Users\Dominique\Documents", true);
|
||||
|
||||
[Fact]
|
||||
public void This_pc_context_does_not_jump_to_a_matching_favorite()
|
||||
{
|
||||
var chosen = TreeRevealSelector.Choose(
|
||||
[Photos, Drive],
|
||||
@"D:\Photos\Vacation",
|
||||
preferFavorites: false,
|
||||
currentlyInFavorites: false);
|
||||
Assert.Equal(Drive, chosen);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Favorite_context_stays_in_the_favorite()
|
||||
{
|
||||
var chosen = TreeRevealSelector.Choose(
|
||||
[Photos, Drive],
|
||||
@"D:\Photos\Vacation",
|
||||
preferFavorites: false,
|
||||
currentlyInFavorites: true);
|
||||
Assert.Equal(Photos, chosen);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Leaving_a_favorite_falls_back_to_the_drive()
|
||||
{
|
||||
var chosen = TreeRevealSelector.Choose(
|
||||
[Photos, Drive],
|
||||
@"D:\Other",
|
||||
preferFavorites: false,
|
||||
currentlyInFavorites: true);
|
||||
Assert.Equal(Drive, chosen);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Prefer_favorites_selects_the_pin_from_this_pc()
|
||||
{
|
||||
var chosen = TreeRevealSelector.Choose(
|
||||
[Photos, Drive],
|
||||
@"D:\Photos\Vacation",
|
||||
preferFavorites: true,
|
||||
currentlyInFavorites: false);
|
||||
Assert.Equal(Photos, chosen);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Home_beats_the_drive_when_favorites_are_not_preferred()
|
||||
{
|
||||
var chosen = TreeRevealSelector.Choose(
|
||||
[FavDocs, HomeDocs, new(@"C:\", false)],
|
||||
@"C:\Users\Dominique\Documents\Work",
|
||||
preferFavorites: false,
|
||||
currentlyInFavorites: false);
|
||||
Assert.Equal(HomeDocs, chosen);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Prefer_favorites_selects_the_documents_pin_over_home()
|
||||
{
|
||||
var chosen = TreeRevealSelector.Choose(
|
||||
[FavDocs, HomeDocs, new(@"C:\", false)],
|
||||
@"C:\Users\Dominique\Documents\Work",
|
||||
preferFavorites: true,
|
||||
currentlyInFavorites: false);
|
||||
Assert.Equal(FavDocs, chosen);
|
||||
}
|
||||
}
|
||||
@@ -57,10 +57,12 @@ public class UiPreferencesStoreTests
|
||||
var prefs = UiPreferencesStore.Parse(
|
||||
[
|
||||
"auto-index-removable=true",
|
||||
"background-host-at-logon=true"
|
||||
"background-host-at-logon=true",
|
||||
"prefer-favorites-in-tree=true"
|
||||
]);
|
||||
Assert.True(prefs.AutoIndexRemovable);
|
||||
Assert.True(prefs.BackgroundHostAtLogon);
|
||||
Assert.True(prefs.PreferFavoritesInTree);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -76,10 +78,32 @@ public class UiPreferencesStoreTests
|
||||
Assert.False(prefs.AutoClearQueueWhenDone);
|
||||
Assert.False(prefs.AutoIndexRemovable);
|
||||
Assert.False(prefs.BackgroundHostAtLogon);
|
||||
Assert.False(prefs.PreferFavoritesInTree);
|
||||
Assert.True(prefs.BackgroundMaintenanceWhenIdle);
|
||||
Assert.Equal(10, prefs.IdleMaintenanceMinutes);
|
||||
Assert.True(prefs.IdleMaintenanceAcOnly);
|
||||
Assert.Null(prefs.SevenZipPath);
|
||||
Assert.Null(prefs.GitPath);
|
||||
Assert.Null(prefs.FfmpegPath);
|
||||
Assert.True(prefs.SessionTabs is null || prefs.SessionTabs.Count == 0);
|
||||
Assert.True(prefs.FavoriteFolders is null || prefs.FavoriteFolders.Count == 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_idle_maintenance_keys()
|
||||
{
|
||||
var prefs = UiPreferencesStore.Parse(
|
||||
[
|
||||
"background-maintenance-when-idle=false",
|
||||
"idle-maintenance-minutes=30",
|
||||
"idle-maintenance-ac-only=false"
|
||||
]);
|
||||
Assert.False(prefs.BackgroundMaintenanceWhenIdle);
|
||||
Assert.Equal(30, prefs.IdleMaintenanceMinutes);
|
||||
Assert.False(prefs.IdleMaintenanceAcOnly);
|
||||
Assert.Equal(5, UiPreferencesStore.NormalizeIdleMinutes(4));
|
||||
Assert.Equal(10, UiPreferencesStore.NormalizeIdleMinutes(10));
|
||||
Assert.Equal(30, UiPreferencesStore.NormalizeIdleMinutes(90));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -127,6 +151,22 @@ public class UiPreferencesStoreTests
|
||||
Assert.True(prefs.SessionTabs[1].ActiveIsRight);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_reads_favorite_folders_and_skips_virtual_roots()
|
||||
{
|
||||
var prefs = UiPreferencesStore.Parse(
|
||||
[
|
||||
"favorite=D:\\Photos",
|
||||
"favorite=D:\\Photos",
|
||||
"favorite=This PC",
|
||||
"favorite=C:\\Users\\Dominique\\Documents"
|
||||
]);
|
||||
Assert.NotNull(prefs.FavoriteFolders);
|
||||
Assert.Equal(2, prefs.FavoriteFolders.Count);
|
||||
Assert.Equal(@"D:\Photos", prefs.FavoriteFolders[0]);
|
||||
Assert.Equal(@"C:\Users\Dominique\Documents", prefs.FavoriteFolders[1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Session_tab_roundtrip_escapes_semicolons_in_paths()
|
||||
{
|
||||
@@ -153,11 +193,13 @@ public class UiPreferencesStoreTests
|
||||
WindowWidth: 1100,
|
||||
WindowHeight: 720,
|
||||
TreeWidth: 300,
|
||||
FavoriteFolders: [@"D:\Photos", @"C:\Users\Dominique\Documents"],
|
||||
SessionTabs:
|
||||
[
|
||||
new SessionTabState(@"C:\Temp", @"D:\", true, 0.6, true)
|
||||
],
|
||||
SessionActiveTab: 0));
|
||||
SessionActiveTab: 0,
|
||||
PreferFavoritesInTree: true));
|
||||
var loaded = store.Load();
|
||||
Assert.Equal("Light", loaded.Theme);
|
||||
Assert.True(loaded.GroupNetworkPlaces);
|
||||
@@ -168,9 +210,14 @@ public class UiPreferencesStoreTests
|
||||
Assert.True(loaded.AutoClearQueueWhenDone);
|
||||
Assert.False(loaded.AutoIndexRemovable);
|
||||
Assert.False(loaded.BackgroundHostAtLogon);
|
||||
Assert.True(loaded.PreferFavoritesInTree);
|
||||
Assert.True(loaded.BackgroundMaintenanceWhenIdle);
|
||||
Assert.Equal(10, loaded.IdleMaintenanceMinutes);
|
||||
Assert.True(loaded.IdleMaintenanceAcOnly);
|
||||
Assert.Equal(1100, loaded.WindowWidth);
|
||||
Assert.Equal(720, loaded.WindowHeight);
|
||||
Assert.Equal(300, loaded.TreeWidth);
|
||||
Assert.Equal([@"D:\Photos", @"C:\Users\Dominique\Documents"], loaded.FavoriteFolders);
|
||||
Assert.NotNull(loaded.SessionTabs);
|
||||
var tab = Assert.Single(loaded.SessionTabs);
|
||||
Assert.Equal(@"C:\Temp", tab.LeftPath);
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
using Explorer.Application;
|
||||
using Explorer.Contracts;
|
||||
using Explorer.Domain;
|
||||
using Explorer.Domain.Abstractions;
|
||||
using Explorer.Hosting;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace Explorer.Hosting.Tests;
|
||||
|
||||
public class BackgroundMaintenanceCoordinatorTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Activity_pauses_hashing_and_idle_indexing()
|
||||
{
|
||||
var hash = new FakeHash();
|
||||
var indexing = new FakeIndexing();
|
||||
var coordinator = Create(hash, indexing, idle: TimeSpan.Zero);
|
||||
await coordinator.TickAsync(CancellationToken.None);
|
||||
Assert.True(hash.IsPaused);
|
||||
Assert.False(indexing.IdleAllowed);
|
||||
Assert.Empty(indexing.IdleScans);
|
||||
Assert.Empty(indexing.IdleVerifies);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Below_idle_threshold_does_not_start()
|
||||
{
|
||||
var hash = new FakeHash();
|
||||
var indexing = new FakeIndexing();
|
||||
var coordinator = Create(hash, indexing, idle: TimeSpan.FromMinutes(3), sources: [StaleLocal()]);
|
||||
await coordinator.TickAsync(CancellationToken.None);
|
||||
Assert.True(hash.IsPaused);
|
||||
Assert.False(indexing.IdleAllowed);
|
||||
Assert.Empty(indexing.IdleScans);
|
||||
Assert.Empty(indexing.IdleVerifies);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Idle_allows_maintenance_and_does_not_enqueue_twice()
|
||||
{
|
||||
var hash = new FakeHash();
|
||||
var indexing = new FakeIndexing();
|
||||
var coordinator = Create(hash, indexing, idle: TimeSpan.FromMinutes(20), sources: [StaleLocal()]);
|
||||
await coordinator.TickAsync(CancellationToken.None);
|
||||
Assert.True(hash.IsPaused);
|
||||
Assert.True(indexing.IdleAllowed);
|
||||
Assert.Equal(new[] { 3L }, indexing.IdleVerifies);
|
||||
Assert.Empty(indexing.IdleScans);
|
||||
|
||||
await coordinator.TickAsync(CancellationToken.None);
|
||||
Assert.Equal(new[] { 3L }, indexing.IdleVerifies);
|
||||
Assert.True(hash.IsPaused);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Activity_resume_pauses_after_idle_work_started()
|
||||
{
|
||||
var hash = new FakeHash();
|
||||
var indexing = new FakeIndexing();
|
||||
var idle = new FakeIdle(TimeSpan.FromMinutes(20));
|
||||
var coordinator = Create(hash, indexing, idle, sources: [StaleLocal()]);
|
||||
await coordinator.TickAsync(CancellationToken.None);
|
||||
Assert.True(hash.IsPaused);
|
||||
Assert.NotEmpty(indexing.IdleVerifies);
|
||||
|
||||
idle.Duration = TimeSpan.Zero;
|
||||
await coordinator.TickAsync(CancellationToken.None);
|
||||
Assert.True(hash.IsPaused);
|
||||
Assert.False(indexing.IdleAllowed);
|
||||
Assert.Equal("Paused because user is active", coordinator.Snapshot.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Disabled_policy_never_starts()
|
||||
{
|
||||
var hash = new FakeHash();
|
||||
var indexing = new FakeIndexing();
|
||||
var coordinator = Create(hash, indexing, idle: TimeSpan.FromMinutes(20), enabled: false, sources: [StaleLocal()]);
|
||||
await coordinator.TickAsync(CancellationToken.None);
|
||||
Assert.True(hash.IsPaused);
|
||||
Assert.False(indexing.IdleAllowed);
|
||||
Assert.Empty(indexing.IdleScans);
|
||||
Assert.Empty(indexing.IdleVerifies);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Foreground_jobs_keep_hashing_paused()
|
||||
{
|
||||
var hash = new FakeHash { Paused = false };
|
||||
var indexing = new FakeIndexing();
|
||||
var coordinator = Create(hash, indexing, idle: TimeSpan.FromMinutes(20), foreground: true, sources: [StaleLocal()]);
|
||||
await coordinator.TickAsync(CancellationToken.None);
|
||||
Assert.True(hash.IsPaused);
|
||||
Assert.False(indexing.IdleAllowed);
|
||||
Assert.Empty(indexing.IdleScans);
|
||||
Assert.Empty(indexing.IdleVerifies);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Battery_skips_when_ac_only()
|
||||
{
|
||||
var hash = new FakeHash();
|
||||
var indexing = new FakeIndexing();
|
||||
var coordinator = Create(hash, indexing, idle: TimeSpan.FromMinutes(20), ac: false, sources: [StaleLocal()]);
|
||||
await coordinator.TickAsync(CancellationToken.None);
|
||||
Assert.True(hash.IsPaused);
|
||||
Assert.Empty(indexing.IdleScans);
|
||||
Assert.Empty(indexing.IdleVerifies);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Run_now_starts_while_user_is_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.True(indexing.IdleAllowed);
|
||||
Assert.Equal(new[] { 3L }, indexing.IdleVerifies);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Idle_verifies_a_fresh_online_source()
|
||||
{
|
||||
var hash = new FakeHash();
|
||||
var indexing = new FakeIndexing();
|
||||
var source = StaleLocal();
|
||||
source.Status = SourceStatus.Online;
|
||||
source.LastIndexedUtc = DateTimeOffset.UtcNow.AddHours(-2);
|
||||
var coordinator = Create(hash, indexing, idle: TimeSpan.FromMinutes(20), sources: [source]);
|
||||
await coordinator.TickAsync(CancellationToken.None);
|
||||
Assert.Equal(new[] { 3L }, indexing.IdleVerifies);
|
||||
Assert.Empty(indexing.IdleScans);
|
||||
}
|
||||
|
||||
private static Source StaleLocal()
|
||||
=> new()
|
||||
{
|
||||
Id = 3,
|
||||
StableKey = "d",
|
||||
DisplayName = "D:",
|
||||
Kind = SourceKind.NtfsLocal,
|
||||
Status = SourceStatus.Stale,
|
||||
LastRootPath = @"D:\",
|
||||
LastIndexedUtc = DateTimeOffset.UtcNow.AddDays(-30)
|
||||
};
|
||||
|
||||
private static BackgroundMaintenanceCoordinator Create(
|
||||
FakeHash hash,
|
||||
FakeIndexing indexing,
|
||||
TimeSpan idle,
|
||||
bool enabled = true,
|
||||
bool ac = true,
|
||||
bool foreground = false,
|
||||
IReadOnlyList<Source>? sources = null)
|
||||
=> Create(hash, indexing, new FakeIdle(idle), enabled, ac, foreground, sources);
|
||||
|
||||
private static BackgroundMaintenanceCoordinator Create(
|
||||
FakeHash hash,
|
||||
FakeIndexing indexing,
|
||||
FakeIdle idle,
|
||||
bool enabled = true,
|
||||
bool ac = true,
|
||||
bool foreground = false,
|
||||
IReadOnlyList<Source>? sources = null)
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "ew-maint", Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var prefs = new UiPreferencesStore(new TempEnv(dir));
|
||||
prefs.Save(UiPreferences.Default with
|
||||
{
|
||||
BackgroundMaintenanceWhenIdle = enabled,
|
||||
IdleMaintenanceMinutes = 10,
|
||||
IdleMaintenanceAcOnly = true
|
||||
});
|
||||
return new BackgroundMaintenanceCoordinator(
|
||||
idle,
|
||||
new FakePower(ac),
|
||||
prefs,
|
||||
new FakeForeground(foreground),
|
||||
indexing,
|
||||
hash,
|
||||
new FakeHistory(),
|
||||
new FakeStore(sources ?? []),
|
||||
new FakeVolumes(),
|
||||
NullLogger<BackgroundMaintenanceCoordinator>.Instance);
|
||||
}
|
||||
|
||||
private sealed class FakeIdle(TimeSpan idle) : IUserIdleMonitor
|
||||
{
|
||||
public TimeSpan Duration { get; set; } = idle;
|
||||
public TimeSpan GetIdleDuration() => Duration;
|
||||
}
|
||||
|
||||
private sealed class FakePower(bool ac) : IPowerSourceMonitor
|
||||
{
|
||||
public bool IsOnAcPower => ac;
|
||||
}
|
||||
|
||||
private sealed class FakeForeground(bool busy) : IForegroundWorkSignal
|
||||
{
|
||||
public bool HasForegroundWork() => busy;
|
||||
}
|
||||
|
||||
private sealed class FakeIndexing : IIdleIndexWork
|
||||
{
|
||||
public bool Busy { get; set; }
|
||||
public bool IdleAllowed { get; private set; }
|
||||
public List<long> IdleScans { get; } = [];
|
||||
public List<long> IdleVerifies { get; } = [];
|
||||
public bool IsBusy => Busy;
|
||||
public bool HasIdleWork => IdleScans.Count > 0 || IdleVerifies.Count > 0;
|
||||
public void SetIdleAllowed(bool allowed) => IdleAllowed = allowed;
|
||||
public void EnqueueIdleFullScan(long sourceId) => IdleScans.Add(sourceId);
|
||||
public void EnqueueIdleVerify(long sourceId, string pathRel) => IdleVerifies.Add(sourceId);
|
||||
}
|
||||
|
||||
private sealed class FakeHash : IIdleHashWork
|
||||
{
|
||||
public bool Paused { get; set; } = true;
|
||||
public bool IsPaused => Paused;
|
||||
public void Pause() => Paused = true;
|
||||
public void Resume() => Paused = false;
|
||||
public void BeginUserRequested() { }
|
||||
public Task<bool> HasPendingAsync(CancellationToken cancellationToken = default) => Task.FromResult(false);
|
||||
}
|
||||
|
||||
private sealed class FakeHistory : IHistoryMaintenance
|
||||
{
|
||||
public Task<bool> TryCaptureAsync(CancellationToken cancellationToken = default) => Task.FromResult(false);
|
||||
}
|
||||
|
||||
private sealed class FakeVolumes : IVolumeService
|
||||
{
|
||||
public IReadOnlyList<VolumeFingerprint> EnumerateOnlineVolumes() => [];
|
||||
public VolumeFingerprint? Probe(string path) => null;
|
||||
public VolumeSpace GetSpace(string path) => default;
|
||||
public bool IsPathReachable(string path) => true;
|
||||
}
|
||||
|
||||
private sealed class FakeStore(IReadOnlyList<Source> sources) : IIndexStore
|
||||
{
|
||||
public ISourceStore Sources { get; } = new FakeSources(sources);
|
||||
public IEntryStore Entries => throw new NotSupportedException();
|
||||
public IExcludeStore Excludes => throw new NotSupportedException();
|
||||
public IScanJobStore ScanJobs => throw new NotSupportedException();
|
||||
public ITransferStore Transfers => throw new NotSupportedException();
|
||||
public ISearchStore Search => throw new NotSupportedException();
|
||||
public IAnalysisStore Analysis => throw new NotSupportedException();
|
||||
public IHistoryStore History => throw new NotSupportedException();
|
||||
public IHashStore Hashes { get; } = new FakeHashes();
|
||||
public IFileRelationStore Relations => throw new NotSupportedException();
|
||||
public IRenameBatchStore RenameBatches => throw new NotSupportedException();
|
||||
public ISyncProfileStore SyncProfiles => throw new NotSupportedException();
|
||||
public IOperationProfileStore OperationProfiles => throw new NotSupportedException();
|
||||
public bool CanWrite => true;
|
||||
public Task OpenAsync(CancellationToken cancellationToken = default) => Task.CompletedTask;
|
||||
public Task CloseAsync() => Task.CompletedTask;
|
||||
public Task<string> QuickCheckAsync(CancellationToken cancellationToken = default) => Task.FromResult("ok");
|
||||
public Task RunWriteAsync(Func<IIndexStore, Task> work, CancellationToken cancellationToken = default) => work(this);
|
||||
public Task<T> RunWriteAsync<T>(Func<IIndexStore, Task<T>> work, CancellationToken cancellationToken = default) => work(this);
|
||||
}
|
||||
|
||||
private sealed class FakeSources(IReadOnlyList<Source> sources) : ISourceStore
|
||||
{
|
||||
public Task<IReadOnlyList<Source>> GetAllAsync(CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(sources);
|
||||
public Task<Source?> GetAsync(long id, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(sources.FirstOrDefault(s => s.Id == id));
|
||||
public Task<Source?> GetByStableKeyAsync(string key, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<Source?>(null);
|
||||
public Task<long> UpsertAsync(Source source, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(source.Id);
|
||||
public Task UpdateStatusAsync(long id, SourceStatus status, string? error, CancellationToken cancellationToken = default)
|
||||
=> Task.CompletedTask;
|
||||
public Task UpdateUsnAsync(long id, long journalId, long nextUsn, CancellationToken cancellationToken = default)
|
||||
=> Task.CompletedTask;
|
||||
public Task UpdateIndexedAsync(long id, DateTimeOffset utc, long generation, CancellationToken cancellationToken = default)
|
||||
=> Task.CompletedTask;
|
||||
public Task SetLastSeenAsync(long id, string rootPath, DateTimeOffset utc, CancellationToken cancellationToken = default)
|
||||
=> Task.CompletedTask;
|
||||
public Task DeleteAsync(long id, CancellationToken cancellationToken = default) => Task.CompletedTask;
|
||||
}
|
||||
|
||||
private sealed class FakeHashes : IHashStore
|
||||
{
|
||||
public Task EnqueueSizeCollisionsAsync(long? sourceId, CancellationToken cancellationToken = default)
|
||||
=> Task.CompletedTask;
|
||||
public Task<bool> HasPendingAsync(CancellationToken cancellationToken = default) => Task.FromResult(false);
|
||||
public Task<IReadOnlyList<HashWorkItem>> DequeueAsync(int take, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<IReadOnlyList<HashWorkItem>>([]);
|
||||
public Task CompletePartialAsync(long entryId, byte[] hash, CancellationToken cancellationToken = default)
|
||||
=> Task.CompletedTask;
|
||||
public Task CompleteFullAsync(long entryId, byte[] hash, CancellationToken cancellationToken = default)
|
||||
=> Task.CompletedTask;
|
||||
public Task MarkUniquePartialAsync(long entryId, CancellationToken cancellationToken = default)
|
||||
=> Task.CompletedTask;
|
||||
public Task MarkErrorAsync(long entryId, CancellationToken cancellationToken = default) => Task.CompletedTask;
|
||||
public Task MarkSkippedAsync(long entryId, CancellationToken cancellationToken = default) => Task.CompletedTask;
|
||||
public Task<bool> HasPartialCollisionAsync(long entryId, long sizeBytes, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(false);
|
||||
public Task<IReadOnlyList<DuplicateGroup>> GetDuplicateGroupsAsync(
|
||||
long? sourceId, string? pathPrefix, int take, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<IReadOnlyList<DuplicateGroup>>([]);
|
||||
}
|
||||
|
||||
private sealed class TempEnv : IAppEnvironment
|
||||
{
|
||||
public TempEnv(string dir)
|
||||
{
|
||||
DataDirectory = 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; }
|
||||
}
|
||||
}
|
||||
@@ -33,7 +33,9 @@ public class CoreRegistrationTests
|
||||
Assert.Null(sp.GetService<IOsClipboard>());
|
||||
Assert.NotNull(sp.GetService<FilesystemScanner>());
|
||||
Assert.NotEmpty(sp.GetServices<IStorageProvider>());
|
||||
Assert.NotNull(sp.GetService<ICloudOverlay>());
|
||||
Assert.NotNull(sp.GetService<IBackgroundMaintenance>());
|
||||
Assert.NotNull(sp.GetService<IUserIdleMonitor>());
|
||||
Assert.NotNull(sp.GetService<IPowerSourceMonitor>());
|
||||
Assert.IsType<StorageProviderRegistry>(sp.GetService<ICloudOverlay>());
|
||||
}
|
||||
finally
|
||||
@@ -76,7 +78,7 @@ public class CoreRegistrationTests
|
||||
Assert.Empty(sp.GetServices<IStorageProvider>());
|
||||
var hosted = sp.GetServices<IHostedService>().ToList();
|
||||
Assert.Contains(hosted, s => s is IndexStoreLifetime);
|
||||
Assert.DoesNotContain(hosted, s => s.GetType().Name is "IndexingCoordinator" or "TransferQueue" or "WatcherHostedService" or "DuplicateHashWorker" or "HistoryRollupService");
|
||||
Assert.DoesNotContain(hosted, s => s.GetType().Name is "IndexingCoordinator" or "TransferQueue" or "WatcherHostedService" or "DuplicateHashWorker" or "HistoryRollupService" or "BackgroundMaintenanceCoordinator");
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
||||
@@ -22,6 +22,9 @@ public class WorkbenchPipeTests
|
||||
var ping = server.Handle(new IpcEnvelope { V = WorkbenchIpc.ProtocolVersion, Op = "Ping" });
|
||||
Assert.True(ping.Ok);
|
||||
|
||||
var ready = server.Handle(new IpcEnvelope { V = WorkbenchIpc.ProtocolVersion, Op = "Host.Ready" });
|
||||
Assert.True(ready.Ok);
|
||||
|
||||
var scan = server.Handle(new IpcEnvelope { V = WorkbenchIpc.ProtocolVersion, Op = "Indexing.EnqueueFullScan", N = 42 });
|
||||
Assert.True(scan.Ok);
|
||||
Assert.Equal(42, indexing.FullScanId);
|
||||
@@ -131,6 +134,18 @@ public class WorkbenchPipeTests
|
||||
Assert.True(ping.Ok);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Host_Ready_needs_a_workbench()
|
||||
{
|
||||
using var sp = new ServiceCollection().BuildServiceProvider();
|
||||
var server = new WorkbenchPipeServer(
|
||||
sp,
|
||||
new WorkbenchIpcOptions(),
|
||||
NullLogger<WorkbenchPipeServer>.Instance);
|
||||
var ready = server.Handle(new IpcEnvelope { V = WorkbenchIpc.ProtocolVersion, Op = "Host.Ready" });
|
||||
Assert.False(ready.Ok);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Ping_roundtrip_over_a_live_named_pipe()
|
||||
{
|
||||
|
||||
@@ -263,6 +263,70 @@ public class ScannerTests
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Verify_reconcile_updates_child_folder_size_after_uninstall()
|
||||
{
|
||||
var root = CreateTree();
|
||||
var unity = Path.Combine(root, "Unity");
|
||||
var editor = Path.Combine(unity, "Editor");
|
||||
Directory.CreateDirectory(editor);
|
||||
await File.WriteAllTextAsync(Path.Combine(editor, "big.bin"), new string('x', 10_000));
|
||||
await File.WriteAllTextAsync(Path.Combine(unity, "leftover.txt"), "ok");
|
||||
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);
|
||||
var before = await store.Entries.GetByPathAsync(source.Id, "Unity");
|
||||
Assert.True(before!.AggregateSize >= 10_000);
|
||||
|
||||
Directory.Delete(editor, recursive: true);
|
||||
var reconciler = new FolderReconciler(store, new IoEnumerator(), new StorageProviderRegistry([], NullLogger<StorageProviderRegistry>.Instance));
|
||||
await reconciler.ReconcileAsync(source, "", CancellationToken.None);
|
||||
var shallow = await store.Entries.GetByPathAsync(source.Id, "Unity");
|
||||
Assert.True(shallow!.AggregateSize >= 10_000);
|
||||
|
||||
await reconciler.ReconcileAsync(source, "", CancellationToken.None, verifyChildren: true);
|
||||
var verified = await store.Entries.GetByPathAsync(source.Id, "Unity");
|
||||
Assert.True(verified!.AggregateSize < 1_000);
|
||||
var editorEntry = await store.Entries.GetByPathAsync(source.Id, @"Unity\Editor");
|
||||
Assert.Equal(EntryStatus.Deleted, editorEntry!.Status);
|
||||
}
|
||||
finally
|
||||
{
|
||||
TryDelete(root);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Verify_from_root_finds_nested_emptied_folder()
|
||||
{
|
||||
var root = CreateTree();
|
||||
var programFiles = Path.Combine(root, "Program Files");
|
||||
var unity = Path.Combine(programFiles, "Unity");
|
||||
var editor = Path.Combine(unity, "Editor");
|
||||
Directory.CreateDirectory(editor);
|
||||
await File.WriteAllTextAsync(Path.Combine(editor, "big.bin"), new string('x', 10_000));
|
||||
await File.WriteAllTextAsync(Path.Combine(unity, "leftover.txt"), "ok");
|
||||
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(editor, recursive: true);
|
||||
var reconciler = new FolderReconciler(store, new IoEnumerator(), new StorageProviderRegistry([], NullLogger<StorageProviderRegistry>.Instance));
|
||||
await reconciler.ReconcileAsync(source, "", CancellationToken.None, verifyChildren: true);
|
||||
var verified = await store.Entries.GetByPathAsync(source.Id, @"Program Files\Unity");
|
||||
Assert.True(verified!.AggregateSize < 1_000);
|
||||
}
|
||||
finally
|
||||
{
|
||||
TryDelete(root);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Folder_reconcile_tombs_removed_file()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user