Public Access
Every test in TransferQueueingTests failed on the Linux runner with "The calling thread cannot access this object because a different thread owns it", and none of them had anything to do with the commits in that run. The class drained its rows through Dispatcher.UIThread.RunJobs(). That dispatcher is process-wide and belongs to whichever thread touched it first, and xunit runs each test class as its own parallel collection — so the moment a runner scheduled another class onto that thread ahead of this one, all nine died inside DispatcherOperation.Execute having asserted nothing about transfers at all. It passes locally and fails on a machine that schedules differently, which is the whole of why this took a CI run to find. TransfersViewModel now takes the poster it marshals through, defaulting to Dispatcher.UIThread.Post — the seam VaultViewModel's clipboard already is, for the same reason: a view model that reaches a process-wide UI object directly makes every test of it depend on a thread it does not choose. No head passes the parameter, so nothing about the running application changes. The test supplies a queue of its own and drains it, which is the same shape the dispatcher gave it. A poster that ran the action inline was tried first and is wrong: the transfer queue raises Changed from its pump thread as well as from the call that enqueued, so inline execution has a background thread adding rows to an ObservableCollection while the test reads it — it passed once and then failed a different test on the next run. Draining keeps every mutation on the thread doing the asserting, which is the one thing the dispatcher was providing that was worth keeping. One test added for the property that broke: queueing is reachable from any thread and must not care which. The class as a whole guards the seam — remove it and nothing drains, so every assertion about a row fails. Verified by reproducing the failure first: a throwaway probe that touched the dispatcher on one thread and posted and drained on another produced exactly the CI message. Then six consecutive Release runs of the app suite, all green, plus the layout suite, which builds a TransfersViewModel of its own. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
258 lines
9.1 KiB
C#
258 lines
9.1 KiB
C#
using System.Collections.Concurrent;
|
|
using DodoSSH.Client.Shell.ViewModels;
|
|
using DodoSSH.Client.Ssh;
|
|
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();
|
|
}
|
|
|
|
/// <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"));
|
|
}
|