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:
2026-08-26 11:21:20 +02:00
parent 6fc7506eb7
commit e2916aef9c
88 changed files with 5373 additions and 544 deletions

View File

@@ -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
};
}

View File

@@ -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);
}
}

View File

@@ -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; }

View File

@@ -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)

View 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);
}
}

View File

@@ -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
};
}

View 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
};
}

View 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);
}
}

View File

@@ -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

View 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);
}
}

View File

@@ -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);