diff --git a/src/DodoSSH.Client.App/Views/MainWindow.axaml b/src/DodoSSH.Client.App/Views/MainWindow.axaml index 5ad6df6..ceefb2f 100644 --- a/src/DodoSSH.Client.App/Views/MainWindow.axaml +++ b/src/DodoSSH.Client.App/Views/MainWindow.axaml @@ -376,12 +376,25 @@ buttons and a version string of unknown length is exactly the shape that arranges one of them off the edge. See UpdateBanner.axaml. + ◆ THE DATA CONTEXT IS SET HERE, and the banner did nothing at all until it was. + + Unlike the titlebar and the status bar, which are typed to this window's own view model and inherit its + context, the banner is typed to UpdateViewModel — it is one screen's control and its layout suite hosts + it over that view model alone. Inheriting the shell's context instead left every compiled binding + inside it resolving against the wrong type and failing silently: no headline, and both Commands null, + so the strip appeared, hovered and pressed like a real banner and neither button did anything. + + IsVisible is unqualified because the context is set on this same element, which resolves it against + UpdateViewModel too — the rule the page area's wrappers above are wrapped for. It needs no wrapper: the + flag it binds is the banner's own, unlike IsHostsScreen and its siblings, which belong to the shell. + FallbackValue, for the reason the WebView and the connecting card carry one: a compiled binding with no DataContext yields UnsetValue, IsVisible falls back to true, and the previewer would show a banner announcing an update that does not exist. --> + DataContext="{Binding Updates}" + IsVisible="{Binding IsBannerShowing, FallbackValue=False}" /> diff --git a/tests/DodoSSH.Client.App.Layout.Tests/UpdateBannerTests.cs b/tests/DodoSSH.Client.App.Layout.Tests/UpdateBannerTests.cs index 8d5be07..c051f17 100644 --- a/tests/DodoSSH.Client.App.Layout.Tests/UpdateBannerTests.cs +++ b/tests/DodoSSH.Client.App.Layout.Tests/UpdateBannerTests.cs @@ -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); } + + /// + /// 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