using Explorer.Domain; using Explorer.Domain.Abstractions; using Explorer.FileOperations; using Explorer.Storage.Sqlite; using Microsoft.Extensions.Logging.Abstractions; namespace Explorer.FileOperations.Tests; public class TransferQueueTests { [Fact] public async Task Copies_run_one_after_another() { await using var ctx = await Harness.CreateAsync(); var firstStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var releaseFirst = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var secondStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); ctx.Shell.OnCopy = src => { if (src.EndsWith("a.txt", StringComparison.OrdinalIgnoreCase)) { firstStarted.TrySetResult(); releaseFirst.Task.GetAwaiter().GetResult(); } else { secondStarted.TrySetResult(); } }; await ctx.Queue.StartAsync(CancellationToken.None); await ctx.Queue.EnqueueCopyAsync([ctx.File("a.txt"), ctx.File("b.txt")], ctx.Dest); await firstStarted.Task.WaitAsync(TimeSpan.FromSeconds(3)); Assert.False(secondStarted.Task.IsCompleted); Assert.Equal(1, ctx.Shell.CopyCount); releaseFirst.TrySetResult(); await secondStarted.Task.WaitAsync(TimeSpan.FromSeconds(3)); await WaitUntil(() => ctx.Queue.Snapshot().Count(j => j.Status == TransferStatus.Done) == 2); Assert.Equal(new[] { ctx.File("a.txt"), ctx.File("b.txt") }, ctx.Shell.Copied); await ctx.Queue.StopAsync(CancellationToken.None); } [Fact] public async Task Pause_keeps_later_jobs_from_starting() { await using var ctx = await Harness.CreateAsync(); var firstStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var awaitingPause = true; ctx.Shell.OnCopy = src => { if (src.EndsWith("a.txt", StringComparison.OrdinalIgnoreCase) && awaitingPause) { firstStarted.TrySetResult(); WaitUntil(() => ctx.Shell.PauseRequested?.Invoke() == true, TimeSpan.FromSeconds(3)) .GetAwaiter().GetResult(); awaitingPause = false; } }; await ctx.Queue.StartAsync(CancellationToken.None); await ctx.Queue.EnqueueCopyAsync([ctx.File("a.txt"), ctx.File("b.txt")], ctx.Dest); await firstStarted.Task.WaitAsync(TimeSpan.FromSeconds(3)); ctx.Queue.PauseAll(); await WaitUntil(() => ctx.Queue.Snapshot()[0].Status == TransferStatus.Paused); Assert.DoesNotContain(ctx.Shell.Copied, p => p.EndsWith("b.txt", StringComparison.OrdinalIgnoreCase)); ctx.Queue.ResumeAll(); await WaitUntil(() => ctx.Queue.Snapshot().All(j => j.Status == TransferStatus.Done)); Assert.Contains(ctx.Shell.Copied, p => p.EndsWith("b.txt", StringComparison.OrdinalIgnoreCase)); await ctx.Queue.StopAsync(CancellationToken.None); } [Fact] public async Task Remove_drops_a_queued_step() { await using var ctx = await Harness.CreateAsync(); ctx.Queue.PauseAll(); await ctx.Queue.StartAsync(CancellationToken.None); await ctx.Queue.EnqueueCopyAsync([ctx.File("a.txt"), ctx.File("b.txt")], ctx.Dest); var jobs = ctx.Queue.Snapshot(); Assert.Equal(2, jobs.Count); ctx.Queue.Cancel(jobs[1].Id); ctx.Queue.ResumeAll(); await WaitUntil(() => ctx.Queue.Snapshot().Any(j => j.Status == TransferStatus.Done)); Assert.Equal(new[] { ctx.File("a.txt") }, ctx.Shell.Copied); Assert.DoesNotContain(ctx.Queue.Snapshot(), j => j.SourcePath.EndsWith("b.txt", StringComparison.OrdinalIgnoreCase) && j.Status is TransferStatus.Queued or TransferStatus.Running or TransferStatus.Done); await ctx.Queue.StopAsync(CancellationToken.None); } [Fact] public async Task Cancel_stops_the_running_copy() { await using var ctx = await Harness.CreateAsync(); var firstStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var releaseFirst = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); ctx.Shell.OnCopy = src => { if (src.EndsWith("a.txt", StringComparison.OrdinalIgnoreCase)) { firstStarted.TrySetResult(); releaseFirst.Task.GetAwaiter().GetResult(); } }; await ctx.Queue.StartAsync(CancellationToken.None); await ctx.Queue.EnqueueCopyAsync([ctx.File("a.txt"), ctx.File("b.txt")], ctx.Dest); await firstStarted.Task.WaitAsync(TimeSpan.FromSeconds(3)); var running = ctx.Queue.Snapshot().Single(j => j.SourcePath.EndsWith("a.txt", StringComparison.OrdinalIgnoreCase)); ctx.Queue.Cancel(running.Id); releaseFirst.TrySetResult(); await WaitUntil(() => ctx.Shell.Copied.Contains(ctx.File("b.txt"))); Assert.DoesNotContain(ctx.Shell.Copied, p => p.EndsWith("a.txt", StringComparison.OrdinalIgnoreCase)); Assert.DoesNotContain(ctx.Queue.Snapshot(), j => j.Id == running.Id && j.Status == TransferStatus.Done); await ctx.Queue.StopAsync(CancellationToken.None); } [Fact] public async Task Pause_queued_job_skips_it() { await using var ctx = await Harness.CreateAsync(); ctx.Queue.PauseAll(); await ctx.Queue.StartAsync(CancellationToken.None); await ctx.Queue.EnqueueCopyAsync([ctx.File("a.txt"), ctx.File("b.txt")], ctx.Dest); var jobs = ctx.Queue.Snapshot(); ctx.Queue.Pause(jobs[0].Id); ctx.Queue.ResumeAll(); await WaitUntil(() => ctx.Shell.Copied.Contains(ctx.File("b.txt"))); Assert.Equal(new[] { ctx.File("b.txt") }, ctx.Shell.Copied); Assert.Equal(TransferStatus.Paused, ctx.Queue.Snapshot().First(j => j.Id == jobs[0].Id).Status); await ctx.Queue.StopAsync(CancellationToken.None); } [Fact] public async Task Reorder_changes_which_job_runs_first() { await using var ctx = await Harness.CreateAsync(); ctx.Queue.PauseAll(); await ctx.Queue.StartAsync(CancellationToken.None); await ctx.Queue.EnqueueCopyAsync([ctx.File("a.txt"), ctx.File("b.txt")], ctx.Dest); var jobs = ctx.Queue.Snapshot(); Assert.True(ctx.Queue.MoveUp(jobs[1].Id)); ctx.Queue.ResumeAll(); await WaitUntil(() => ctx.Queue.Snapshot().Count(j => j.Status == TransferStatus.Done) == 2); Assert.Equal(new[] { ctx.File("b.txt"), ctx.File("a.txt") }, ctx.Shell.Copied); await ctx.Queue.StopAsync(CancellationToken.None); } private static async Task WaitUntil(Func condition, TimeSpan? timeout = null) { var limit = DateTime.UtcNow + (timeout ?? TimeSpan.FromSeconds(4)); while (!condition()) { if (DateTime.UtcNow > limit) { throw new TimeoutException("Condition was not met."); } await Task.Delay(20); } } private sealed class Harness : IAsyncDisposable { public required TransferQueue Queue { get; init; } public required GateShell Shell { get; init; } public required SqliteIndexStore Store { get; init; } public required string Dest { get; init; } public required string Root { get; init; } public string File(string name) => Path.Combine(Root, name); public static async Task CreateAsync() { var root = Path.Combine(Path.GetTempPath(), "ew-xfer", Guid.NewGuid().ToString("N")); Directory.CreateDirectory(root); var dest = Path.Combine(root, "dest"); Directory.CreateDirectory(dest); System.IO.File.WriteAllText(Path.Combine(root, "a.txt"), "a"); System.IO.File.WriteAllText(Path.Combine(root, "b.txt"), "b"); var db = Path.Combine(root, "index.db"); var store = new SqliteIndexStore(db, NullLogger.Instance); await store.OpenAsync(); var shell = new GateShell(); var queue = new TransferQueue(shell, new DiskEnum(), store, NullLogger.Instance); return new Harness { Queue = queue, Shell = shell, Store = store, Dest = dest, Root = root }; } public async ValueTask DisposeAsync() { await Store.DisposeAsync(); try { Directory.Delete(Root, true); } catch { /* ignore */ } } } } internal sealed class DiskEnum : IFileSystemEnumerator { public IEnumerable EnumerateChildren(string directoryPath) => EnumerateChildrenSafe(directoryPath, out _); public IReadOnlyList EnumerateChildrenSafe(string directoryPath, out string? error) { error = null; return Directory.Exists(directoryPath) ? Directory.GetFileSystemEntries(directoryPath).Select(GetRequired).ToList() : []; } public FileSystemItem? GetItem(string path) => System.IO.File.Exists(path) || Directory.Exists(path) ? GetRequired(path) : null; private static FileSystemItem GetRequired(string path) { var isDir = Directory.Exists(path); return new FileSystemItem { FullPath = path, Name = Path.GetFileName(path), IsDirectory = isDir, SizeBytes = isDir ? 0 : new FileInfo(path).Length }; } } internal sealed class GateShell : IShellFileOperations { public int CopyCount { get; private set; } public List Copied { get; } = []; public Action? OnCopy { get; set; } public Func? PauseRequested { get; private set; } public void Open(string path) { } public bool DeleteToRecycleBin(IReadOnlyList paths, out string? error) => Delete(paths, true, out error); public bool Delete(IReadOnlyList paths, bool recycle, out string? error) { error = null; return true; } public bool CreateShortcut(string targetPath, string shortcutPath, out string? error) { error = null; return true; } public bool CopyFileWithProgress(string source, string destination, bool overwrite, IProgress? progress, CancellationToken cancellationToken, out string? error, Func? pauseRequested = null) { CopyCount++; PauseRequested = pauseRequested; progress?.Report(1); OnCopy?.Invoke(source); if (cancellationToken.IsCancellationRequested) { error = "Cancelled"; return false; } if (pauseRequested?.Invoke() == true) { error = "Paused"; return false; } Copied.Add(source); Directory.CreateDirectory(Path.GetDirectoryName(destination)!); System.IO.File.Copy(source, destination, overwrite); progress?.Report(new FileInfo(source).Length); error = null; return true; } public bool MoveFileWithProgress(string source, string destination, bool overwrite, IProgress? progress, CancellationToken cancellationToken, out string? error, Func? pauseRequested = null) => CopyFileWithProgress(source, destination, overwrite, progress, cancellationToken, out error, pauseRequested); }