Public Access
ci / build and test (pull_request) Successful in 2m30s
ci / desktop nightly (pull_request) Skipped
ci / android head (pull_request) Successful in 7m21s
ci / api image (pull_request) Successful in 23s
ci / build and test (push) Successful in 2m28s
ci / android head (push) Successful in 3m27s
ci / desktop nightly (push) Successful in 53s
ci / api image (push) Successful in 36s
Three things the file browser could not do. Somewhere to go had to be walked to, a file that landed under the wrong name could only be removed and sent again, and the listing went on showing the directory as it was before this application put something in it. ◆ THE PATH IS A BOX NOW, not a TextBlock printing where the pane was. A breadcrumb only reaches what is under the directory already listed, so crossing a host's filesystem cost a click and a round trip a level, and a path somebody already knew could not be entered at all. Enter goes; Escape puts back what the pane is really showing. What is typed may be absolute, relative to the directory showing, or start at ~ — the four shapes a shell takes, because somebody typing into a box is typing what they would type into a shell. SftpPath.Resolve turns all of them into one absolute path, and it resolves them here rather than handing a relative path to the server: the server would resolve it against the session's own working directory, which is not necessarily the directory the breadcrumb above the box is showing. `..` is collapsed textually, which differs from cd through a symbolic link; the alternative is a realpath round trip before anything lists. The box holds its own value rather than RemotePath itself. Binding it straight to the path would navigate on every keystroke — `/`, then `/v`, then `/va` — a round trip and a failed listing per character. Every other way of moving writes the real path back into it; a refused listing deliberately does not, because the path that did not resolve is the one worth having in front of you. ◆ RENAME AND MOVE, and DELETE moves to join them. It had been sitting in the pane header beside UP and REFRESH — two things that move the pane and one that destroys a file, in one row — and the three that act on the selected row are now together in a row of their own, under the name of the row they are about. One SFTP rename underneath both new operations and two separate questions on the screen, because the answer is a different kind of thing each time: a rename takes a name and must not take a path, a move takes a directory and must not take a name. One box asking for a destination path would be fewer controls and a worse question — the two mistakes it invites are exactly the ones the split makes impossible. Each form opens on the answer that changes nothing, so what gets edited is the half somebody came to change. Three refusals are made here, and they are the ones a server's message would not explain: a separator in a name (no such file, about a directory nobody meant to name), a destination equal to the source (success, and a file that did not move), and a directory into itself (EINVAL, true and useless). Everything else is the server's — a name already taken, a path that is not there, a mount that cannot be crossed — and arrives in the status line in its own words. Neither is painted in the deletion card's red: a rename is undone by renaming back, and spending that red twice makes it stop meaning anything. ◆ THE LISTING REFRESHES ITSELF when a transfer lands in it. Both panes, since an upload lands on the host and a download lands here and either leaves the pane that is showing that directory a step behind. Only the directory actually on screen: a queue moving a hundred files elsewhere must not be a round trip per file for a listing nobody is looking at. It waits rather than interrupting. Another operation running would have two listings racing for one collection, and re-listing under an open question is how the row somebody answered about stops being the row that gets acted on — so the staleness is remembered and settled at the end of the operation or when the question is dismissed. It says nothing either way: nobody asked for it, and the status line at that moment is carrying the news about the transfer. The selection survives, re-found by path, which is what makes an unasked-for refresh safe to run under somebody's pointer at all. ◆ THE LAYOUT SUITE FOUND A REAL FAULT and the fix is where the height goes. The rename form did not fit the 204 pixels the session shell's own sidebar leaves this pane — MKDIR and the listing were pushed off the bottom. The form is shorter now (the placeholder says what the box wants, so there is no field label above it), and the item row and the breadcrumb step aside while any of the three questions is up, which is what the phone head already did with its own buttons. Measuring also settled something the markup had been claiming and is not true: at that width the listing collapses to nothing under any of the three cards, the deletion card included, and has done since the sidebar arrived. So "the row it is about is still on screen" is retired from the deletion card's remark, and the full path each card carries is what identifies the row instead. Recorded where it is drawn rather than left to be rediscovered. TESTS. SftpPath.Resolve gets the four shapes, ~ with and without a home to expand it to, a blank box, and climbing past the root. ShellFlowTests gets nine against a session that records what it was asked to do — which path was renamed, which directories were listed — rather than against the sentence this code writes about itself: the path box absolute and relative, rename in place, the separator refusal, a move by relative path, the no-op refusal, the three questions displacing each other, and the three rules the automatic refresh follows. The local half is in TransferQueueingTests, where a real folder answers. Two new shapes are measured, and the deletion card's own measurement moves to the connected state — the only state it can be asked in, and the narrow one. What the tests cannot reach is in docs/manual-checks.md as phase 2b: a real server's cross-device and name-taken refusals, and a soft keyboard, which is why the phone's path box has a GO button rather than trusting whatever the IME decided to draw on its return key.
388 lines
14 KiB
C#
388 lines
14 KiB
C#
using System.Collections.Concurrent;
|
|
using DodoSSH.Client.Shell.ViewModels;
|
|
using DodoSSH.Client.Ssh;
|
|
using DodoSSH.Client.Transfer;
|
|
using NSubstitute;
|
|
|
|
namespace DodoSSH.Client.App.Tests;
|
|
|
|
/// <summary>
|
|
/// What may be queued for transfer, and what is said about the rest.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// 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.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>What these cannot cover is the drag itself.</b> 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 <c>docs/manual-checks.md</c> — and what
|
|
/// is automated is the half that a person checking by eye would most easily get wrong: the counting.
|
|
/// </para>
|
|
/// </remarks>
|
|
public sealed class TransferQueueingTests : IDisposable
|
|
{
|
|
private readonly string directory =
|
|
Path.Combine(Path.GetTempPath(), $"dodossh-drop-{Guid.CreateVersion7():N}");
|
|
|
|
/// <summary>What the view model has posted and <see cref="Queued"/> has not run yet.</summary>
|
|
/// <remarks>
|
|
/// A queue of this test's own, standing in for the dispatcher — which is what it replaces. Draining
|
|
/// <c>Dispatcher.UIThread</c> 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.
|
|
/// <para>
|
|
/// A queue rather than a poster that runs the action inline, and the difference is not stylistic: the
|
|
/// transfer queue raises <c>Changed</c> from its pump thread as well as from the call that enqueued,
|
|
/// so inline execution would have a background thread adding rows to an <c>ObservableCollection</c>
|
|
/// 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.
|
|
/// </para>
|
|
/// </remarks>
|
|
private readonly ConcurrentQueue<Action> posted = new();
|
|
|
|
private readonly TransfersViewModel transfers;
|
|
|
|
public TransferQueueingTests()
|
|
{
|
|
Directory.CreateDirectory(directory);
|
|
|
|
transfers = new TransfersViewModel(
|
|
Substitute.For<ISftpSessionFactory>(),
|
|
TimeProvider.System,
|
|
posted.Enqueue);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public void Dispose()
|
|
{
|
|
if (Directory.Exists(directory))
|
|
{
|
|
Directory.Delete(directory, recursive: true);
|
|
}
|
|
}
|
|
|
|
/// <remarks>
|
|
/// 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.
|
|
/// </remarks>
|
|
[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");
|
|
}
|
|
|
|
/// <remarks>
|
|
/// 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.
|
|
/// </remarks>
|
|
[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");
|
|
}
|
|
|
|
/// <remarks>
|
|
/// 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.
|
|
/// </remarks>
|
|
[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");
|
|
}
|
|
|
|
/// <remarks>
|
|
/// 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.
|
|
/// </remarks>
|
|
[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");
|
|
}
|
|
|
|
/// <remarks>
|
|
/// 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 <c>Dispatcher.UIThread</c> 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
|
|
/// <see cref="Queued"/> explains; what is asserted here is the half that has no business caring.
|
|
/// </remarks>
|
|
[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();
|
|
}
|
|
|
|
/// <remarks>
|
|
/// 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.
|
|
/// </remarks>
|
|
[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");
|
|
}
|
|
|
|
/// <remarks>
|
|
/// <para>
|
|
/// 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 <c>docs/manual-checks.md</c> 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.
|
|
/// </para>
|
|
/// </remarks>
|
|
[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);
|
|
}
|
|
|
|
/// <remarks>
|
|
/// <para>
|
|
/// The local half of the automatic refresh. A download that finished left this pane showing the folder
|
|
/// as it was before the file arrived, so the file this application had just fetched was not in the
|
|
/// listing it was fetched into — and the way to see it was to press REFRESH.
|
|
/// </para>
|
|
/// <para>
|
|
/// Synchronous, unlike its remote counterpart, and that is the whole reason it is nothing more than a
|
|
/// re-read where it stands: a local listing is a filesystem call, so there is no round trip to defer and
|
|
/// no busy state to wait out. See <c>TransfersViewModel.SettleRemoteListingAsync</c> for the other side.
|
|
/// </para>
|
|
/// </remarks>
|
|
[Fact]
|
|
public void ADownloadThatLandsInTheFolderShowing_ReReadsItWithoutBeingAsked()
|
|
{
|
|
Connected();
|
|
|
|
transfers.RefreshLocalCommand.Execute(null);
|
|
transfers.LocalEntries.ShouldBeEmpty();
|
|
|
|
var landed = File("fetched.bin");
|
|
|
|
transfers.NoteTransferLanded(new TransferSnapshot(
|
|
Guid.CreateVersion7(),
|
|
TransferDirection.Download,
|
|
"fetched.bin",
|
|
landed,
|
|
"/srv/app/fetched.bin",
|
|
128,
|
|
128,
|
|
TransferState.Completed,
|
|
BytesPerSecond: 0,
|
|
Failure: null));
|
|
|
|
transfers.LocalEntries.Select(row => row.Name).ShouldBe(["fetched.bin"]);
|
|
}
|
|
|
|
/// <remarks>
|
|
/// Only the folder on screen, for the reason its remote counterpart gives: a queue writing into
|
|
/// somewhere nobody is looking at would otherwise re-read a directory per completed transfer.
|
|
/// </remarks>
|
|
[Fact]
|
|
public void ADownloadThatLandsSomewhereElse_ChangesNothingOnScreen()
|
|
{
|
|
Connected();
|
|
|
|
transfers.RefreshLocalCommand.Execute(null);
|
|
|
|
File("elsewhere.bin");
|
|
|
|
transfers.NoteTransferLanded(new TransferSnapshot(
|
|
Guid.CreateVersion7(),
|
|
TransferDirection.Download,
|
|
"elsewhere.bin",
|
|
Path.Combine(Path.GetTempPath(), "somewhere-else", "elsewhere.bin"),
|
|
"/srv/app/elsewhere.bin",
|
|
128,
|
|
128,
|
|
TransferState.Completed,
|
|
BytesPerSecond: 0,
|
|
Failure: null));
|
|
|
|
transfers.LocalEntries.ShouldBeEmpty();
|
|
}
|
|
|
|
/// <summary>The queue's rows, once the posts that create them have been let run.</summary>
|
|
/// <remarks>
|
|
/// <c>TransfersViewModel</c> adds a row from the transfer queue's own <c>Changed</c> 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.
|
|
/// </remarks>
|
|
private IReadOnlyList<TransferRowViewModel> 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"));
|
|
}
|