using DodoSSH.Client.Session; using DodoSSH.Client.Shell.ViewModels; namespace DodoSSH.Client.App.Tests; /// /// Finding a newer build, fetching it, and not installing it until somebody says so. /// /// /// /// The view model is driven a pass at a time through CheckOnceAsync rather than through its timer, /// which is the same split VaultViewModel makes between its sync pass and its sync loop and for the /// same reason: a suite that waited on a PeriodicTimer would be testing the clock. /// /// /// The load-bearing test here is . Everything else is /// about how the feature behaves; that one is about the promise it makes. /// /// public sealed class UpdateFlowTests : IDisposable { private static CancellationToken Token => TestContext.Current.CancellationToken; private readonly string directory = Path.Combine(Path.GetTempPath(), $"dodossh-updates-{Guid.CreateVersion7():N}"); private readonly FakeUpdateChannel channel = new(); private int liveSessions; private int restarts; private ClientPaths Paths => new(directory); /// /// The real settings store over a real temporary directory, not a stand-in. Persistence is one of the /// things being asserted, and the file is the thing that persists. /// private UpdateViewModel Build() => new( channel, new ClientSettingsStore(Paths), TimeProvider.System, () => liveSessions, restart: update => { restarts++; channel.ApplyAndRestart(update); return Task.CompletedTask; }); /// public void Dispose() { if (Directory.Exists(directory)) { Directory.Delete(directory, recursive: true); } } [Fact] public void ABuildThatCannotReplaceItself_OffersNothing() { channel.IsSupported = false; var updates = Build(); updates.State.ShouldBe(UpdateState.Unsupported); updates.IsUnsupported.ShouldBeTrue(); updates.CanCheckNow.ShouldBeFalse(); updates.IsBannerShowing.ShouldBeFalse(); } [Fact] public void ABuildThatCannotReplaceItself_StillKnowsItsOwnVersion() { channel.IsSupported = false; channel.CurrentVersion = "0.4.2"; Build().CurrentVersion.ShouldBe("0.4.2"); } [Fact] public async Task ABackgroundPassThatFindsNothing_SaysNothing() { var updates = Build(); await updates.CheckOnceAsync(Token); channel.Checks.ShouldBe(1); updates.State.ShouldBe(UpdateState.Idle); updates.Status.ShouldBeEmpty(); } [Fact] public async Task APressedCheckThatFindsNothing_SaysSo() { channel.CurrentVersion = "1.2.0"; var updates = Build(); await updates.CheckNowCommand.ExecuteAsync(null); updates.State.ShouldBe(UpdateState.Idle); updates.Status.ShouldContain("1.2.0"); } [Fact] public async Task AnUpdateThatIsFound_IsFetchedWithoutBeingAskedAbout() { channel.Available = new AvailableUpdate("1.3.0"); var updates = Build(); await updates.CheckOnceAsync(Token); channel.Downloads.ShouldBe(1); updates.State.ShouldBe(UpdateState.Ready); updates.ReadyVersion.ShouldBe("1.3.0"); updates.DownloadPercent.ShouldBe(100); updates.IsBannerShowing.ShouldBeTrue(); } /// /// The whole policy in one assertion. A fetched update sits until a person presses the button, or until /// the application is next started for their own reasons — because a restart ends every shell, and this /// application has spent a lot of design effort on shells surviving a lock. /// [Fact] public async Task AReadyUpdate_IsNeverAppliedOnItsOwn() { channel.Available = new AvailableUpdate("1.3.0"); var updates = Build(); await updates.CheckOnceAsync(Token); updates.State.ShouldBe(UpdateState.Ready); channel.Restarts.ShouldBe(0); restarts.ShouldBe(0); } [Fact] public async Task RestartingApplies_TheUpdateThatWasFound() { channel.Available = new AvailableUpdate("1.3.0"); var updates = Build(); await updates.CheckOnceAsync(Token); await updates.RestartNowCommand.ExecuteAsync(null); channel.Restarts.ShouldBe(1); channel.RestartedWith!.Version.ShouldBe("1.3.0"); } /// /// ◆ The regression for an install that closed the application instead of installing anything. On the /// phone applying is a request that returns, so a failure in it throws on the spot rather than being /// cut short by the process ending — and with nothing catching it, it left a command handler, passed /// the dispatcher and took the process with it. Pressing INSTALL closed the application, said nothing, /// and installed nothing, which reads as anything but a bug in the updater. /// [Fact] public async Task AnInstallThatFails_IsReportedRatherThanThrown() { channel.Available = new AvailableUpdate("1.3.0"); channel.ApplyingEndsTheProcess = false; channel.RestartFailure = new InvalidOperationException("The installer refused the session."); var updates = Build(); await updates.CheckOnceAsync(Token); // Not Should.ThrowAsync: the assertion is that this does not throw at all. await updates.RestartNowCommand.ExecuteAsync(null); updates.State.ShouldBe(UpdateState.Failed); updates.Status.ShouldBe("The installer refused the session."); } [Fact] public async Task RestartingWithNothingReady_DoesNothing() { var updates = Build(); await updates.RestartNowCommand.ExecuteAsync(null); channel.Restarts.ShouldBe(0); } [Fact] public async Task DismissingTheBanner_KeepsTheOfferOnPreferences() { channel.Available = new AvailableUpdate("1.3.0"); var updates = Build(); await updates.CheckOnceAsync(Token); updates.DismissBannerCommand.Execute(null); updates.IsBannerShowing.ShouldBeFalse(); updates.IsReady.ShouldBeTrue(); } /// /// A forge that cannot be reached is a laptop on a train. Nothing asked, so nothing is said, and the /// state goes back to Idle rather than to Failed — Failed is reserved for something a person is waiting /// on an answer to. /// [Fact] public async Task ABackgroundCheckThatFails_IsNotAnnounced() { channel.CheckFailure = new HttpRequestException("no such host"); var updates = Build(); await updates.CheckOnceAsync(Token); updates.State.ShouldBe(UpdateState.Idle); updates.Status.ShouldBeEmpty(); } [Fact] public async Task APressedCheckThatFails_SaysWhy() { channel.CheckFailure = new HttpRequestException("no such host"); var updates = Build(); await updates.CheckNowCommand.ExecuteAsync(null); updates.State.ShouldBe(UpdateState.Failed); updates.Status.ShouldBe("no such host"); } [Fact] public async Task TurningOffAutomaticChecks_StopsThePassDoingAnything() { channel.Available = new AvailableUpdate("1.3.0"); var updates = Build(); updates.IsAutomatic = false; await updates.CheckOnceAsync(Token); channel.Checks.ShouldBe(0); } /// /// A second view model over the same , which is how this repository tests that /// a preference reached the disk — the same shape as the terminal font size's own persistence test. /// [Fact] public void TurningOffAutomaticChecks_IsStillOffOnTheNextLaunch() { var first = Build(); first.IsAutomatic.ShouldBeTrue(); first.IsAutomatic = false; Build().IsAutomatic.ShouldBeFalse(); } /// Storing one preference does not discard the others. /// /// /// Pins the read-modify-write in OnIsAutomaticChanged, which nothing else covered: writing this /// setting reads the file first, so a font size chosen earlier is still there afterwards. Writing the /// view model's own state instead would silently reset every preference it does not hold. /// /// /// Note the limit of what read-modify-write buys here, because the comment it guards is easy to read /// as promising more. It preserves settings this build knows about. A key written by a newer /// build is dropped, because the store deserialises with /// JsonUnmappedMemberHandling.Skip — unmapped members are skipped rather than carried, so they /// do not survive a round trip. That is asserted below rather than left as an assumption, so that /// anybody who needs forward-compatibility discovers the cost here instead of in the field. /// /// [Fact] public void StoringOnePreference_KeepsTheOthersThisBuildKnows() { Directory.CreateDirectory(directory); File.WriteAllText( Paths.SettingsFile, """{"terminalFontSize":19,"somethingOnlyANewerBuildKnows":"keep me"}"""); Build().IsAutomatic = false; var reread = new ClientSettingsStore(Paths).Read(); reread.TerminalFontSize.ShouldBe(19); reread.AutomaticUpdateChecks.ShouldBeFalse(); // The honest limit, stated as an assertion: an unmapped key does not survive. File.ReadAllText(Paths.SettingsFile).ShouldNotContain("somethingOnlyANewerBuildKnows"); } /// /// A setting missing from the file reads back as the default the record declares. /// /// /// /// This is the regression test for a defect that was already in the tree and could not bite until a /// second preference existed. The JSON source generator builds a record through a synthesised /// parameterised constructor and assigns every property from its argument array, so a member /// absent from the file arrived as the CLR default and overwrote whatever a property initializer had /// set. A settings file of {} read back a font size of 0 — clamped to the 8px floor rather than /// the 13px the renderer draws at — and, once it existed, update checks off. /// /// /// It is asserted on both settings, not just the new one, because the mechanism has nothing to do with /// either: it is a property of how this record is deserialised, and the next preference somebody adds /// inherits it. The fix is that the defaults live on the constructor parameters, which is the only /// place the generator reads them from. /// /// /// A file that exists and lacks the key is the case that matters, and it is not the same as no file at /// all — Read short-circuits to a fresh record when the file is missing, which is why the bug /// hid. Every machine that has ever run this application has a settings.json without the newer key. /// /// [Fact] public void ASettingAbsentFromTheFile_ComesBackAsItsDeclaredDefault() { Directory.CreateDirectory(directory); var store = new ClientSettingsStore(Paths); File.WriteAllText(Paths.SettingsFile, "{}"); var fromEmptyObject = store.Read(); fromEmptyObject.AutomaticUpdateChecks.ShouldBeTrue(); fromEmptyObject.TerminalFontSize.ShouldBe(ClientSettings.DefaultTerminalFontSize); // The realistic shape: a file written by the build before this feature existed. File.WriteAllText(Paths.SettingsFile, """{"terminalFontSize":19}"""); var fromOlderBuild = store.Read(); fromOlderBuild.TerminalFontSize.ShouldBe(19); fromOlderBuild.AutomaticUpdateChecks.ShouldBeTrue(); } [Fact] public async Task TheRestartWarning_CountsTheShellsItWouldClose() { channel.Available = new AvailableUpdate("1.3.0"); var updates = Build(); await updates.CheckOnceAsync(Token); liveSessions = 0; updates.RestartWarning.ShouldContain("Nothing is connected"); liveSessions = 1; updates.RestartWarning.ShouldContain("the shell you have open"); liveSessions = 3; updates.RestartWarning.ShouldContain("the 3 shells you have open"); } [Fact] public async Task ADownloadInFlight_IsAStateTheScreenCanShow() { channel.Available = new AvailableUpdate("1.3.0"); channel.HoldDownload = new TaskCompletionSource(); var updates = Build(); var pass = updates.CheckOnceAsync(Token); // The gate is what makes this observable at all; without it the download would be over before // anything could look. while (updates.State is not UpdateState.Downloading) { await Task.Yield(); } updates.IsDownloading.ShouldBeTrue(); updates.CanCheckNow.ShouldBeFalse(); channel.HoldDownload.SetResult(); await pass; updates.State.ShouldBe(UpdateState.Ready); } [Fact] public async Task APassWhileOneIsAlreadyReady_DoesNotAskAgain() { channel.Available = new AvailableUpdate("1.3.0"); var updates = Build(); await updates.CheckOnceAsync(Token); await updates.CheckOnceAsync(Token); channel.Checks.ShouldBe(1); } [Fact] public async Task DisposingStopsTheLoop() { var updates = Build(); updates.Start(); await updates.DisposeAsync(); await updates.DisposeAsync(); } }