using System.Collections.Concurrent; using DodoSSH.Client.Shell.ViewModels; using DodoSSH.Client.Ssh; using NSubstitute; namespace DodoSSH.Client.App.Tests; /// /// What may be queued for transfer, and what is said about the rest. /// /// /// /// This is the whole of what drag and drop decides. The handlers on the screen extract paths or rows from a /// drop and hand them here; every rule about which of them can be moved, which are skipped and what the /// status line says lives in the view model, where it needs no window. /// /// /// What these cannot cover is the drag itself. Headless Avalonia has no native window and cannot /// synthesise a platform drag, so a test that pretended to drop a file from the file manager would pass /// while confirming nothing. The wiring is verified by hand — see docs/manual-checks.md — and what /// is automated is the half that a person checking by eye would most easily get wrong: the counting. /// /// public sealed class TransferQueueingTests : IDisposable { private readonly string directory = Path.Combine(Path.GetTempPath(), $"dodossh-drop-{Guid.CreateVersion7():N}"); /// What the view model has posted and has not run yet. /// /// A queue of this test's own, standing in for the dispatcher — which is what it replaces. Draining /// Dispatcher.UIThread meant depending on which thread a runner happened to touch that /// process-wide object from first, and once another class got there first every test here died on /// "the calling thread cannot access this object" having asserted nothing about transfers at all. /// /// A queue rather than a poster that runs the action inline, and the difference is not stylistic: the /// transfer queue raises Changed from its pump thread as well as from the call that enqueued, /// so inline execution would have a background thread adding rows to an ObservableCollection /// while the test reads it. Draining keeps every mutation on the thread doing the asserting, which is /// the one thing the dispatcher was providing that is worth keeping. /// /// private readonly ConcurrentQueue posted = new(); private readonly TransfersViewModel transfers; public TransferQueueingTests() { Directory.CreateDirectory(directory); transfers = new TransfersViewModel( Substitute.For(), TimeProvider.System, posted.Enqueue); } /// public void Dispose() { if (Directory.Exists(directory)) { Directory.Delete(directory, recursive: true); } } /// /// The refusal that has to happen before anything else: there is nowhere to put a file until a host is /// connected, and a queue that filled up first would start failing the moment one was. /// [Fact] public void DroppingFilesWhileDisconnected_QueuesNothingAndSaysWhy() { transfers.IsConnected = false; transfers.QueueUploads([File("one.txt")]); Queued().ShouldBeEmpty(); transfers.Status.ShouldContain("Connect to a host first"); } [Fact] public void DroppingSeveralFiles_QueuesEachOfThem() { Connected(); transfers.QueueUploads([File("one.txt"), File("two.txt"), File("three.txt")]); Queued().Count.ShouldBe(3); transfers.Status.ShouldContain("3 files"); transfers.Status.ShouldContain("/srv/app"); } /// /// The queue moves files. There is no recursive upload, and a folder dragged in and silently ignored /// looks exactly like a transfer that failed to start — so it is counted and reported. /// [Fact] public void DroppingAFolderAmongFiles_SkipsItAndSaysSo() { Connected(); var folder = Path.Combine(directory, "a-folder"); Directory.CreateDirectory(folder); transfers.QueueUploads([File("one.txt"), folder]); Queued().ShouldHaveSingleItem(); transfers.Status.ShouldContain("1 file"); transfers.Status.ShouldContain("1 folder was skipped"); } /// /// The paths in an operating-system drop come from another process and are not obliged to still be /// right by the time the drop lands. /// [Fact] public void DroppingAFileThatHasGone_SkipsItAndSaysSo() { Connected(); transfers.QueueUploads([File("one.txt"), Path.Combine(directory, "never-existed.txt")]); Queued().ShouldHaveSingleItem(); transfers.Status.ShouldContain("1 item was no longer there"); } [Fact] public void DroppingOnlyFolders_QueuesNothingAndDoesNotClaimOtherwise() { Connected(); var folder = Path.Combine(directory, "a-folder"); Directory.CreateDirectory(folder); transfers.QueueUploads([folder]); Queued().ShouldBeEmpty(); transfers.Status.ShouldContain("Nothing was queued"); transfers.Status.ShouldContain("1 folder was skipped"); } [Fact] public void DroppingRemoteRowsOnTheLocalPane_QueuesDownloads() { Connected(); transfers.QueueDownloads([RemoteFile("one.log"), RemoteFile("two.log")]); Queued().Count.ShouldBe(2); transfers.Status.ShouldContain("2 files"); transfers.Status.ShouldContain("download into"); } [Fact] public void DroppingARemoteDirectory_SkipsItAndSaysSo() { Connected(); transfers.QueueDownloads([RemoteFile("one.log"), RemoteDirectory("logs")]); Queued().ShouldHaveSingleItem(); transfers.Status.ShouldContain("1 folder was skipped"); } /// /// The buttons were the only way to queue anything before drag and drop, and they now go through the /// same two methods — so there is one set of rules rather than two that have to agree. This is what /// says they still do. /// [Fact] public void TheDownloadButton_GoesThroughTheSamePathAsADrop() { Connected(); transfers.SelectedRemoteEntry = RemoteFile("one.log"); transfers.DownloadCommand.Execute(null); Queued().ShouldHaveSingleItem(); // And refuses a directory in the same words, rather than with the button's own message. transfers.SelectedRemoteEntry = RemoteDirectory("logs"); transfers.DownloadCommand.Execute(null); Queued().Count.ShouldBe(1); transfers.Status.ShouldContain("1 folder was skipped"); } /// /// Queueing is reachable from any thread — a drop is handled on the UI thread, a retry is not — and /// nothing about it may depend on which one. Worth stating because the version of this class that /// drained Dispatcher.UIThread did depend on exactly that, and said so only by failing in CI on /// a machine whose scheduling differed. The draining still happens on the test's own thread, as /// explains; what is asserted here is the half that has no business caring. /// [Fact] public void QueueingFromAnotherThread_StillEnqueues() { Exception? failure = null; var thread = new Thread(() => { try { Connected(); transfers.QueueUploads([File("one.txt")]); } catch (Exception exception) { failure = exception; } }); thread.Start(); thread.Join(); failure.ShouldBeNull(); Queued().ShouldHaveSingleItem(); } /// /// The phone's way in, and it has to obey the same rules as every other: a document chosen in the system /// picker is copied into the cache and the copy is queued, which is an upload with one extra property — /// that this application made the file and will delete it again. Everything about *what may be queued* /// is the same, and this says so rather than leaving a second path free to drift. /// [Fact] public void StagedUploads_QueueUnderTheSameRulesAsAnyOther() { Connected(); var folder = Path.Combine(directory, "a-folder"); Directory.CreateDirectory(folder); transfers.QueueStagedUploads([File("picked.txt"), folder]); Queued().ShouldHaveSingleItem(); transfers.Status.ShouldContain("1 file"); transfers.Status.ShouldContain("1 folder was skipped"); } /// /// /// The phone's way out. The delivery itself — copying the finished file into the document the save /// picker made — needs a transfer that actually runs and a picker to have made something, so it is /// checked by hand in docs/manual-checks.md phase 14. What is worth pinning here is the pair of /// refusals in front of it, because both would otherwise be discovered as an empty file sitting in /// somebody's Downloads: the picker creates the destination the moment it is dismissed, so anything /// this method turns away after that point has already cost a visible artefact. /// /// [Fact] public void ADeliveredDownload_QueuesTheFileAndRefusesADirectory() { var delivered = 0; Connected(); transfers.QueueDeliveredDownload( RemoteFile("one.log"), Path.Combine(directory, "staged", "one.log"), _ => { delivered++; return Task.CompletedTask; }); Queued().ShouldHaveSingleItem(); transfers.Status.ShouldContain("one.log"); // A directory has nothing to fetch, and the message is the same one every other path on this screen // gives for the same mistake. transfers.QueueDeliveredDownload( RemoteDirectory("logs"), Path.Combine(directory, "staged", "logs"), _ => Task.CompletedTask); Queued().Count.ShouldBe(1); transfers.Status.ShouldContain("Only files"); // Nothing is delivered by queueing. The callback runs when the bytes are there and not before. delivered.ShouldBe(0); } /// The queue's rows, once the posts that create them have been let run. /// /// TransfersViewModel adds a row from the transfer queue's own Changed event, which it /// marshals because the queue raises it from a pump thread. Nothing drains that here, so the posts are /// run by hand — the alternative is asserting on the status line alone, which is a string this code /// wrote about itself and proves nothing about anything having been enqueued. /// private IReadOnlyList Queued() { while (posted.TryDequeue(out var action)) { action(); } return transfers.Transfers; } private void Connected() { transfers.IsConnected = true; transfers.RemotePath = "/srv/app"; transfers.LocalPath = directory; } private string File(string name) { var path = Path.Combine(directory, name); System.IO.File.WriteAllText(path, "contents"); return path; } private static RemoteEntryRowViewModel RemoteFile(string name) => new( new SftpEntry(name, $"/srv/app/{name}", SftpEntryKind.File, 128, DateTimeOffset.UnixEpoch, "-rw-r--r--")); private static RemoteEntryRowViewModel RemoteDirectory(string name) => new( new SftpEntry(name, $"/srv/app/{name}", SftpEntryKind.Directory, 0, DateTimeOffset.UnixEpoch, "drwxr-xr-x")); }