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:
@@ -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()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user