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