Public Access
The banner has never worked. It went into MainWindow's fourth row with no data context of its own, so it inherited the shell's — and it is the one control in that file typed to a screen's view model rather than to MainWindowViewModel, because it is the only one with a layout suite that hosts it over UpdateViewModel alone. Compiled bindings type-check against x:DataType at runtime, so every binding inside it resolved against the wrong object and failed the way a compiled binding does: quietly. No headline, and DismissBannerCommand and RestartNowCommand both null. A button with a null command is enabled, hovers, depresses and does nothing, which is why this looked like a hit-testing problem and why the WebView was the first suspect. It is not one. The strip is a sibling row for the reason the occlusion rule gives and that arrangement is correct — the terminal's rectangle is never covered, only shortened. What was actually on offer was an announcement that an update had been downloaded, with two buttons that refused to install it and no way to make it go away either. The preferences screen's RESTART NOW worked throughout, because it binds Updates.RestartNowCommand from the shell's own context, which is the contrast that pins the cause. The context is set on the banner itself and IsVisible loses its Updates. prefix with it, because a data context on an element resolves that element's other bindings too — the rule the page area's wrappers upstairs exist to work around. Those wrappers are needed because IsHostsScreen and its siblings belong to the shell; IsBannerShowing belongs to the banner's own view model, so there is nothing to wrap here. Neither existing suite could have caught it. A layout test supplies the data context it is measuring, which is exactly the assumption that was wrong, and the shell suite has no visual tree — its project file already says it does not cover whether the XAML binds to the right names. So the new test asserts the wiring rather than the layout: a real shell over the ready-update fake, MainWindow constructed and never shown, and the banner asked what context it got, whether it is visible and whether RESTART NOW carries a command. Checked failing with the one attribute removed. Constructing the window is safe where showing it is not, and nothing here needs it shown: a data context propagates when it is set, not when the tree is measured.
243 lines
9.2 KiB
C#
243 lines
9.2 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// The update banner, measured at the width the window is allowed to shrink to.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// 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 <em>not</em>
|
|
/// inside a <see cref="ScrollViewer"/> — 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.
|
|
/// </para>
|
|
/// <para>
|
|
/// It cannot be measured through <c>MainWindow</c>, which is why the banner is its own control: WebView2's
|
|
/// adapter refuses the headless session's thread. See <c>LayoutHarnessTests.WhyTheWindowItselfIsNeverShown</c>.
|
|
/// </para>
|
|
/// </remarks>
|
|
public sealed class UpdateBannerTests
|
|
{
|
|
private static CancellationToken Token => TestContext.Current.CancellationToken;
|
|
|
|
/// <summary>
|
|
/// A channel that reports an update is ready, so the banner has something to draw.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Local to this suite rather than shared with <c>DodoSSH.Client.App.Tests</c>: 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.
|
|
/// </remarks>
|
|
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<AvailableUpdate?> CheckAsync(CancellationToken cancellationToken) =>
|
|
Task.FromResult<AvailableUpdate?>(new AvailableUpdate(LongVersion));
|
|
|
|
public Task DownloadAsync(
|
|
AvailableUpdate update,
|
|
IProgress<int> progress,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
progress.Report(100);
|
|
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
public void ApplyAndRestart(AvailableUpdate update)
|
|
{
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// A version longer than anything this project should ever tag, which is the point.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// MinVer produces exactly this shape on an untagged commit — <c>0.0.0-alpha.0.114</c> — and a release
|
|
/// candidate would produce something close to it. A banner that only fits <c>1.2.3</c> would look fine
|
|
/// in every screenshot and break on the first prerelease anybody actually shipped.
|
|
/// </remarks>
|
|
private const string LongVersion = "10.20.30-release-candidate.4";
|
|
|
|
private static async Task<UpdateViewModel> 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);
|
|
}
|
|
|
|
/// <remarks>
|
|
/// Held to the number <see cref="LayoutHarness.UpdateBannerHeight"/> 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.
|
|
/// </remarks>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// The banner in the window that shows it, rather than in a host window this suite built.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// The two tests above hand the control an <see cref="UpdateViewModel"/> 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 <c>MainWindow</c> 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 <c>Command</c>s null, and hovered and
|
|
/// depressed like a working banner while neither button did anything.
|
|
/// </para>
|
|
/// <para>
|
|
/// 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.
|
|
/// </para>
|
|
/// <para>
|
|
/// The window is constructed and never shown, for the reason
|
|
/// <see cref="LayoutHarnessTests.WhyTheWindowItselfIsNeverShown"/> 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.
|
|
/// </para>
|
|
/// </remarks>
|
|
[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<string, TerminalAsset>(StringComparer.Ordinal)),
|
|
Substitute.For<ISshConnectionFactory>(),
|
|
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<IDeviceKeyStore>(),
|
|
(_, _) => throw new NotSupportedException("nothing here signs in"),
|
|
TimeProvider.System,
|
|
Substitute.For<ISftpSessionFactory>(),
|
|
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<UpdateBanner>().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<Button>("RestartNowButton").ShouldNotBeNull();
|
|
|
|
restart.Command.ShouldNotBeNull();
|
|
}
|
|
finally
|
|
{
|
|
window.Close();
|
|
}
|
|
}
|
|
}
|