using Avalonia.Controls;
using Avalonia.LogicalTree;
using DodoSSH.Client.App.Views;
using DodoSSH.Client.Session;
using DodoSSH.Client.Shell.ViewModels;
using DodoSSH.Client.Ssh;
using DodoSSH.Client.Storage;
using DodoSSH.Client.Terminal;
using NSubstitute;
namespace DodoSSH.Client.App.Layout.Tests;
///
/// The update banner, measured at the width the window is allowed to shrink to.
///
///
///
/// This is the control in the application least protected by anything else. It is a fixed-height row with
/// two buttons on the right and a version string of unknown length in the middle, and it is not
/// inside a — so the harness's scrolling exemption does not apply and both
/// buttons are genuinely measured. The failure it exists to catch is RESTART NOW pushed off the right edge
/// by a long version, which nobody would see until the day there was an update to install.
///
///
/// It cannot be measured through MainWindow, which is why the banner is its own control: WebView2's
/// adapter refuses the headless session's thread. See LayoutHarnessTests.WhyTheWindowItselfIsNeverShown.
///
///
public sealed class UpdateBannerTests
{
private static CancellationToken Token => TestContext.Current.CancellationToken;
///
/// A channel that reports an update is ready, so the banner has something to draw.
///
///
/// Local to this suite rather than shared with DodoSSH.Client.App.Tests: what a layout test needs
/// is a view model in a given state, and threading the other suite's fake through a project reference
/// would couple two suites that otherwise share nothing.
///
private sealed class ReadyChannel : IUpdateChannel
{
public bool IsSupported => true;
// The desktop's answer, which is what this suite lays out. Nothing here applies anything.
public bool ApplyingEndsTheProcess => true;
public string CurrentVersion => "1.0.0";
public Task CheckAsync(CancellationToken cancellationToken) =>
Task.FromResult(new AvailableUpdate(LongVersion));
public Task DownloadAsync(
AvailableUpdate update,
IProgress progress,
CancellationToken cancellationToken)
{
progress.Report(100);
return Task.CompletedTask;
}
public void ApplyAndRestart(AvailableUpdate update)
{
}
}
///
/// A version longer than anything this project should ever tag, which is the point.
///
///
/// MinVer produces exactly this shape on an untagged commit — 0.0.0-alpha.0.114 — and a release
/// candidate would produce something close to it. A banner that only fits 1.2.3 would look fine
/// in every screenshot and break on the first prerelease anybody actually shipped.
///
private const string LongVersion = "10.20.30-release-candidate.4";
private static async Task ReadyViewModelAsync()
{
var directory = Path.Combine(Path.GetTempPath(), $"dodossh-banner-{Guid.CreateVersion7():N}");
var updates = new UpdateViewModel(
new ReadyChannel(),
new ClientSettingsStore(new ClientPaths(directory)),
TimeProvider.System,
() => 3,
restart: _ => Task.CompletedTask);
await updates.CheckOnceAsync(TestContext.Current.CancellationToken);
if (Directory.Exists(directory))
{
Directory.Delete(directory, recursive: true);
}
return updates;
}
[Fact]
public async Task TheUpdateBannerFitsAtTheWindowsMinimumWidth()
{
var updates = await ReadyViewModelAsync();
await LayoutHarness.OnTheUiThreadAsync(
() =>
{
var banner = new UpdateBanner { DataContext = updates };
var window = LayoutHarness.HostAtMinimumSize(
banner, LayoutHarness.MinimumWidth, LayoutHarness.UpdateBannerHeight);
try
{
LayoutHarness.Unreachable(window).ShouldBeEmpty();
}
finally
{
window.Close();
}
},
Token);
}
///
/// Held to the number states, the same way the titlebar,
/// tab strip and status bar are. A banner that grew with its contents would shrink the terminal by an
/// amount nothing predicts, and would do it while somebody is reading it.
///
[Fact]
public async Task TheUpdateBannerIsTheHeightItSaysItIs()
{
var updates = await ReadyViewModelAsync();
await LayoutHarness.OnTheUiThreadAsync(
() =>
{
var banner = new UpdateBanner { DataContext = updates };
var window = LayoutHarness.HostAtMinimumSize(
banner, LayoutHarness.MinimumWidth, LayoutHarness.UpdateBannerHeight);
try
{
banner.Bounds.Height.ShouldBe(LayoutHarness.UpdateBannerHeight);
}
finally
{
window.Close();
}
},
Token);
}
///
/// The banner in the window that shows it, rather than in a host window this suite built.
///
///
///
/// The two tests above hand the control an themselves — which is precisely
/// what the window did not do. The banner is the one control here typed to a screen's view model instead
/// of to the shell's, and it was dropped into MainWindow with no data context at all, so it
/// inherited the shell's: every compiled binding inside it then resolved against the wrong type and
/// failed silently. The strip appeared, with no headline and both Commands null, and hovered and
/// depressed like a working banner while neither button did anything.
///
///
/// Nothing else could have caught it. A layout test supplies the context it is measuring, and the shell
/// suite has no visual tree — its own project file says as much: it does not cover whether the XAML binds
/// to the right names.
///
///
/// The window is constructed and never shown, for the reason
/// gives, and it does not need to be: a
/// data context propagates and a binding resolves when the context is set, not when the tree is measured.
/// So this asserts the wiring and leaves every question of size to the two tests above.
///
///
[Fact]
public async Task TheWindowHandsTheBannerTheUpdateViewModel()
{
var directory = Path.Combine(Path.GetTempPath(), $"dodossh-banner-shell-{Guid.CreateVersion7():N}");
using var caches = ClientCacheFactory.ForMemory($"banner-{Guid.CreateVersion7():N}");
await using var workspace = new TerminalWorkspace(
new InMemoryTerminalAssetProvider(new Dictionary(StringComparer.Ordinal)),
Substitute.For(),
TimeProvider.System);
// The real shell view model, because the thing under test is what the window hands the banner. No
// vault and no sign-in: the banner is drawn outside the unlocked half of the window on purpose.
await using var shell = new MainWindowViewModel(
new ClientPaths(directory),
caches,
workspace,
new VaultKnownHostStore(),
Substitute.For(),
(_, _) => throw new NotSupportedException("nothing here signs in"),
TimeProvider.System,
Substitute.For(),
updates: new ReadyChannel());
try
{
await shell.Updates.CheckOnceAsync(Token);
shell.Updates.IsBannerShowing.ShouldBeTrue();
await LayoutHarness.OnTheUiThreadAsync(() => AssertTheBannerIsWiredTo(shell), Token);
}
finally
{
if (Directory.Exists(directory))
{
Directory.Delete(directory, recursive: true);
}
}
}
private static void AssertTheBannerIsWiredTo(MainWindowViewModel shell)
{
var window = new MainWindow { DataContext = shell };
try
{
var banner = window.GetLogicalDescendants().OfType().ShouldHaveSingleItem();
banner.DataContext.ShouldBeSameAs(shell.Updates);
banner.IsVisible.ShouldBeTrue();
// The defect itself: a null command is a button that answers a click by doing nothing, and it
// is indistinguishable from a working one until somebody presses it.
var restart = banner.FindControl