Merge branch 'claude/windows-update-bar-buttons-b19b2d'
ci / build and test (push) Successful in 1m55s
ci / android head (push) Successful in 3m25s
ci / desktop nightly (push) Successful in 43s
ci / api image (push) Successful in 24s

This commit is contained in:
2026-08-06 12:36:18 +02:00
2 changed files with 108 additions and 1 deletions
@@ -1,7 +1,12 @@
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;
@@ -145,4 +150,93 @@ public sealed class UpdateBannerTests
},
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();
}
}
}