diff --git a/CHANGELOG.md b/CHANGELOG.md index 166cc51..1c79bc4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,6 +53,19 @@ is not part of this repository. button stayed off. Such a selection cannot be carried out, and the sheet now says so, with the entries named above it and a sentence saying to deselect them or deal with each one on its own. A plan for one such entry still asks for its name, as before. +- While a plan is being carried out, Escape and the close mark on the sheet no longer put the + sheet away. Putting it away used to let a second plan be carried out beside the first, after + which Interrupt and the guard against closing the window followed only the second one. The close + mark is greyed while the run goes on and says why. A preview or the details of a row asked for + during a run are refused, with the reason in the line at the bottom of the window. +- A plan that could not be carried out at all no longer reports "Done. All 0 entries are where you + asked." The sheet says nothing was carried out. +- The list is read again after a plan has run even when the window was in the middle of another + reading at that moment. The Startup type column used to keep showing the old value until F5. + F5 pressed while signatures are being verified is carried out once they are done, instead of + doing nothing. +- After a reading of the list fails once and the next one works, the window says so straight away. + It used to go on saying it could not read the list until something on the machine changed. ## [0.3.0] - 2026-09-25 diff --git a/src/Bws.Gui/MainWindow.Carrying.cs b/src/Bws.Gui/MainWindow.Carrying.cs index 843971f..9bfcb50 100644 --- a/src/Bws.Gui/MainWindow.Carrying.cs +++ b/src/Bws.Gui/MainWindow.Carrying.cs @@ -91,10 +91,16 @@ internal Func, Task /// a statement about pixels, and the press that matters is the one arriving while a run is /// already going - from a second click, or from the keyboard, at the moment the first one has /// not yet reached the screen. + /// + /// And the run itself is asked about, not only the panel, since 2026-09-29 - G-1 of the + /// external stability report. Until then the panel could be put away mid-run, which lowered the + /// flag this reads, and a second run started beside the first. The panel now refuses that + /// (Planned.CanClose), and this refuses independently, because the fields below belong to ONE + /// run and a second one would overwrite them. /// internal async Task CarryOut() { - if (!_model.Planned.CanCarryOut || _model.Planned.Plan is not { } plan) + if (_running is { IsCompleted: false } || !_model.Planned.CanCarryOut || _model.Planned.Plan is not { } plan) { return false; } @@ -147,13 +153,6 @@ internal async Task CarryOut() _running = running; _model.Planned.Finished(await running.ConfigureAwait(true)); - - // Whatever moved, moved. Asking now rather than waiting up to a second means the list - // agrees with the panel by the time somebody looks up from it. KEEPING what is known - // about files since 2026-09-29 - the plan wrote none, `ADR-13`. - await _model.LoadKeepingAsync().ConfigureAwait(true); - - return true; } catch (InvalidOperationException refusal) { @@ -184,6 +183,20 @@ internal async Task CarryOut() // A no-op on every ordinary path, because Finished has already lowered the flag. _model.Planned.NoLongerRunning(); } + + // Whatever moved, moved. Asking now rather than waiting up to a second means the list + // agrees with the panel by the time somebody looks up from it. KEEPING what is known about + // files since 2026-09-29 - the plan wrote none, `ADR-13`. + // + // OUTSIDE THE TRY SINCE 2026-09-29 - G-2 of the external stability report. Inside it, an + // InvalidOperationException from refreshing the LIST landed in the catch meant for a refused + // PLAN, which recorded an empty run over the real one - so a run that had changed the + // machine was reported as "Done" over nothing, and its failures and way back were gone. A + // refresh that throws now goes to the window's own net and the report stays. It also runs + // after the fields above are cleared, so the run is over before the list is asked again. + await _model.LoadKeepingAsync().ConfigureAwait(true); + + return true; } /// diff --git a/src/Bws.Gui/MainWindow.Details.cs b/src/Bws.Gui/MainWindow.Details.cs index 2169039..ab34fce 100644 --- a/src/Bws.Gui/MainWindow.Details.cs +++ b/src/Bws.Gui/MainWindow.Details.cs @@ -43,6 +43,16 @@ internal bool OpenDetailsOf(EntryRow entry) { ArgumentNullException.ThrowIfNull(entry); + // THE SHEET KEEPS THE COLUMN WHILE ITS RUN IS GOING, since 2026-09-29 - G-1, argued at + // Planned.CanClose. It refuses to be put away, so opening the details beside it would put + // two panels in one column. + if (_model.Planned.Busy) + { + _model.Says.CouldNotDo(Texts.Of("gui.plan.blocked.running")); + + return false; + } + PointAt(entry); _model.Chosen.Row = entry; diff --git a/src/Bws.Gui/MainWindow.Keyboard.cs b/src/Bws.Gui/MainWindow.Keyboard.cs index f96d072..5a65fc4 100644 --- a/src/Bws.Gui/MainWindow.Keyboard.cs +++ b/src/Bws.Gui/MainWindow.Keyboard.cs @@ -213,7 +213,16 @@ internal bool Act(Shortcut shortcut, out Task work) // innermost thing there is to back out of, and it is open only while the keyboard // is in the box - so the press that closes it is the press somebody makes with a // query they are still typing, which is the one Escape must not take away. - return _model.Suggesting.Close() || _model.Planned.Hide() || _model.Chosen.Hide() || _model.ClearQuery(); + // + // A RUN THAT IS GOING TAKES THE PRESS AND DOES NOTHING WITH IT, since 2026-09-29 - + // G-1, argued at Planned.CanClose. The sheet refuses to go away, and without this the + // press would fall through that refusal to the query and empty it under a running + // plan. The sheet's own line already says a run is going, and Interrupt is on it. + return _model.Suggesting.Close() + || _model.Planned.Busy + || _model.Planned.Hide() + || _model.Chosen.Hide() + || _model.ClearQuery(); default: return false; diff --git a/src/Bws.Gui/MainWindow.Menu.cs b/src/Bws.Gui/MainWindow.Menu.cs index 6f1d35c..5216504 100644 --- a/src/Bws.Gui/MainWindow.Menu.cs +++ b/src/Bws.Gui/MainWindow.Menu.cs @@ -210,6 +210,16 @@ internal bool OpenDetailsOfPointed() => /// internal async Task Preview(ActionKind kind, StartSetting? to = null, bool alsoStop = false) { + // NOT WHILE A PLAN IS BEING CARRIED OUT, since 2026-09-29 - G-1, argued at + // Planned.CanClose. The sheet would refuse the new plan anyway, and this says so where a + // menu item can say anything at all: the status line, rule 8. + if (_model.Planned.Busy) + { + _model.Says.CouldNotDo(Texts.Of("gui.plan.blocked.running")); + + return false; + } + var picked = PickedRows(); var names = Everything(picked); diff --git a/src/Bws.Gui/PlanView.xaml b/src/Bws.Gui/PlanView.xaml index 73e5e1c..b117462 100644 --- a/src/Bws.Gui/PlanView.xaml +++ b/src/Bws.Gui/PlanView.xaml @@ -152,13 +152,17 @@ internal bool Hide() { - if (!Showing) + if (!Showing || Busy) { return false; } diff --git a/src/Bws.Gui/ViewModels/Readings.cs b/src/Bws.Gui/ViewModels/Readings.cs index 95303e4..c321bdc 100644 --- a/src/Bws.Gui/ViewModels/Readings.cs +++ b/src/Bws.Gui/ViewModels/Readings.cs @@ -120,6 +120,18 @@ internal sealed partial class Readings /// Whether the last reading failed outright, which is not the same as admitting gaps. private bool _failed; + /// + /// A full reading somebody asked for while another was out, carried out once that one is back. + /// + /// Owed rather than dropped since 2026-09-29 - G-5 of the external stability report. The + /// window asks for a reading after a plan has run, and a tick or a second pass still out at that + /// moment swallowed the ask: the start type column went on showing what the plan had just + /// changed until somebody pressed F5, and F5 itself did nothing while signatures were being + /// read. One reading owed, never a queue - the strongest of what was asked, because a fresh + /// look answers a keeping one too. + /// + private Relisting? _owed; + /// /// Whether any reading has ever come back with an answer. Raised once and never lowered. /// @@ -173,12 +185,16 @@ internal Readings( /// Reads the machine in full and fills the list. The first reading, and whatever F5 asks /// for afterwards. /// - /// A second call arriving while one is out is dropped rather than queued. Without that, - /// two presses of F5 send two readings and the one that finished later wins rather - /// than the one that read later - so the list can settle on the older of two - /// answers and say nothing about it. Nothing here corrupts, because every continuation - /// comes back to the interface thread, which is precisely why the hole was invisible: it - /// is a question of ordering rather than of two threads touching one field. + /// A second call arriving while one is out never runs BESIDE it. Without that, two presses of + /// F5 send two readings and the one that finished later wins rather than the one that + /// read later - so the list can settle on the older of two answers and say nothing + /// about it. Nothing here corrupts, because every continuation comes back to the interface + /// thread, which is precisely why the hole was invisible: it is a question of ordering rather + /// than of two threads touching one field. + /// + /// It is OWED rather than dropped since 2026-09-29 (), and the + /// argument above still holds: the owed reading starts after the one that was out has + /// finished, so the one that reads later is also the one that finishes later. /// /// /// Afresh for the first look and F5, keeping file answers after a plan - . @@ -187,6 +203,8 @@ internal async Task LoadAsync(Relisting how) { if (_reading) { + _owed = how == Relisting.Afresh || _owed == Relisting.Afresh ? Relisting.Afresh : Relisting.Keeping; + return; } @@ -200,6 +218,24 @@ internal async Task LoadAsync(Relisting how) { _reading = false; } + + await Owed().ConfigureAwait(true); + } + + /// + /// The reading asked for while another was out, if there is one - see . + /// Nothing for a window that has gone, which nobody is reading for any more. + /// + private Task Owed() + { + if (_owed is not { } how || _gone) + { + return Task.CompletedTask; + } + + _owed = null; + + return LoadAsync(how); } /// @@ -305,65 +341,92 @@ internal async Task RefreshAsync() try { - // Before asking what moved, because a question the window cannot answer yet is a worse - // thing to leave standing than a status that is one second old. - if (WantsMore()) - { - // Keeping, on the owner's word of 2026-09-29: turning a column on is not F5, and the - // signatures already on screen were verified since the last one. - await LoadEverything(Relisting.Keeping).ConfigureAwait(true); + await Tick().ConfigureAwait(true); + } + finally + { + _reading = false; + } + + // A full reading asked for while this tick was out is carried out now rather than lost - + // see _owed. After the flag is down, so it goes through the same door as any other. + await Owed().ConfigureAwait(true); + } + + /// + /// The tick itself, with the flag already held. Its own method since 2026-09-29, so that every + /// way out of it - three of them return early - still reaches the owed reading above. + /// + private async Task Tick() + { + // Before asking what moved, because a question the window cannot answer yet is a worse + // thing to leave standing than a status that is one second old. + if (WantsMore()) + { + // Keeping, on the owner's word of 2026-09-29: turning a column on is not F5, and the + // signatures already on screen were verified since the last one. + await LoadEverything(Relisting.Keeping).ConfigureAwait(true); - return; - } + return; + } - IReadOnlyList statuses; + IReadOnlyList statuses; - try - { - statuses = await Task.Run(_catalog.ReadStatuses).ConfigureAwait(true); - } + try + { + statuses = await Task.Run(_catalog.ReadStatuses).ConfigureAwait(true); + } #pragma warning disable CA1031 - // Same argument as above, and it earns its place here rather than inheriting it: - // this runs unattended once a second, so an exception nobody caught would take the - // window down while its owner was somewhere else entirely. - catch (Exception failure) - { - Fail(failure); - - return; - } -#pragma warning restore CA1031 + // Same argument as above, and it earns its place here rather than inheriting it: + // this runs unattended once a second, so an exception nobody caught would take the + // window down while its owner was somewhere else entirely. + catch (Exception failure) + { + Fail(failure); - if (_gone) - { - return; - } - - Says.Incomplete = false; - _everRead = true; - - switch (_index.Absorb(statuses)) - { - case Freshening.CompositionChanged: - // The unguarded one, because the guard above is already held. Calling the - // public entry point here would find its own flag raised and quietly do - // nothing, which is the sort of deadlock-by-politeness that looks like the - // machine simply never installing anything. Keeping file answers: what was - // installed or removed brings its own path, and that one is verified. - await LoadEverything(Relisting.Keeping).ConfigureAwait(true); - break; - - case Freshening.Moved: - _settled(); - break; + return; + } +#pragma warning restore CA1031 - default: - break; - } + if (_gone) + { + return; } - finally + + Says.Incomplete = false; + _everRead = true; + + // A TICK THAT WORKS AFTER ONE THAT FAILED BRINGS THE WINDOW BACK, since 2026-09-29 - + // G-6 of the external stability report. Only a full reading used to lower the flag, so + // one failed tick left "could not read" in the status line, and an empty answer showed + // the failed face rather than "nothing matches", until something moved. + var recovered = _failed; + _failed = false; + + switch (_index.Absorb(statuses)) { - _reading = false; + case Freshening.CompositionChanged: + // The unguarded one, because the guard above is already held. Calling the + // public entry point here would find its own flag raised and quietly do + // nothing, which is the sort of deadlock-by-politeness that looks like the + // machine simply never installing anything. Keeping file answers: what was + // installed or removed brings its own path, and that one is verified. + await LoadEverything(Relisting.Keeping).ConfigureAwait(true); + break; + + case Freshening.Moved: + _settled(); + break; + + default: + // Nothing moved, but the status line and the empty face still say the reading + // failed - _settled rewrites both. + if (recovered) + { + _settled(); + } + + break; } } diff --git a/src/Bws.Gui/ViewModels/RowIndex.cs b/src/Bws.Gui/ViewModels/RowIndex.cs index 66d187d..71120bc 100644 --- a/src/Bws.Gui/ViewModels/RowIndex.cs +++ b/src/Bws.Gui/ViewModels/RowIndex.cs @@ -38,7 +38,16 @@ internal sealed class RowIndex /// Every row, in the manager's order. What a query is applied to. public IReadOnlyList Ordered => _order; - /// Rebuilds every row from a full reading, keeping the rows that already exist. + /// + /// Rebuilds every row from a full reading, keeping the rows that already exist. + /// + /// One row per name, the first one the manager handed over, since 2026-09-29 - X-1 of + /// the external stability report. A listing read in two turns while services come and go (a + /// per-user family at logon) may in principle hand one name over twice - nobody has seen it, + /// and it cannot be ruled out from here. The second copy used to go into the order as the SAME + /// row object a second time, and the list's own reconciliation is built on every row being in + /// it once. A name is identity (`ADR-14`), so two copies of it are one entry. + /// public void Absorb(IReadOnlyList entries) { var now = _clock.Now; @@ -47,7 +56,10 @@ public void Absorb(IReadOnlyList entries) foreach (var entry in entries) { - seen.Add(entry.ServiceName); + if (!seen.Add(entry.ServiceName)) + { + continue; + } if (_byName.TryGetValue(entry.ServiceName, out var row)) { diff --git a/tests/Bws.Gui.Tests/MainViewModelTests.cs b/tests/Bws.Gui.Tests/MainViewModelTests.cs index 703dab2..4339957 100644 --- a/tests/Bws.Gui.Tests/MainViewModelTests.cs +++ b/tests/Bws.Gui.Tests/MainViewModelTests.cs @@ -533,7 +533,12 @@ public async Task Two_readings_are_never_out_at_once() await first; - Assert.Equal(reads + 1, machine.FullReads); + // TWO, ONE AFTER THE OTHER, SINCE 2026-09-29 - G-5 of the external stability report. The + // second full reading used to be dropped, and that dropped the one the window asks for once + // a plan has run. It is owed now and starts when the first is back - which keeps what this + // test is about, because the one that looks later is still the one that finishes later. The + // tick is still turned away: ticks come every second and owe nothing. + Assert.Equal(reads + 2, machine.FullReads); Assert.Equal(0, machine.StatusReads); } diff --git a/tests/Bws.Gui.Tests/OwedReadingTests.cs b/tests/Bws.Gui.Tests/OwedReadingTests.cs new file mode 100644 index 0000000..19763a3 --- /dev/null +++ b/tests/Bws.Gui.Tests/OwedReadingTests.cs @@ -0,0 +1,76 @@ +using Bws.Gui.ViewModels; + +namespace Bws.Gui.Tests; + +/// +/// Readings that used to be lost or left stale - G-5, G-6 and X-1 of the external stability report, +/// 2026-09-29. +/// +/// G-5. A full reading asked for while another was out was dropped, so the one the window +/// asks for after a plan has run could vanish under a tick, and the list kept showing what the plan +/// had just changed. G-6. Only a full reading lowered the failed flag, so one failed tick left +/// the window saying it could not read until something moved. X-1. A name handed over twice +/// went into the list twice as the same row. +/// +public sealed class OwedReadingTests +{ + [Fact] + public async Task A_full_reading_asked_for_while_a_tick_is_out_is_carried_out_after_it() + { + var machine = new LiveMachine(Rows.Entry("Spooler", "Print Spooler"), Rows.Entry("W32Time", "Windows Time")); + var model = new MainViewModel(machine, new SteppedClock()); + + await model.LoadAsync(); + + machine.HoldReadings(); + + var tick = model.RefreshAsync(); + + // What the window asks for once a plan has run - while the tick is still inside the manager. + await model.LoadKeepingAsync(); + + // Something only a full reading sees. + machine.Rename("Spooler", "Print Spooler, renamed"); + machine.ReleaseReadings(); + + await tick; + + Assert.Equal(2, machine.FullReads); + Assert.Equal("Print Spooler, renamed", model.Rows.Single(row => row.ServiceName == "Spooler").DisplayName); + } + + [Fact] + public async Task A_tick_that_works_after_one_that_failed_brings_the_window_back() + { + var machine = new LiveMachine(Rows.Entry("Spooler", "Print Spooler"), Rows.Entry("W32Time", "Windows Time")); + var model = new MainViewModel(machine, new SteppedClock()); + + await model.LoadAsync(); + + // A question nothing answers, so the empty face is the thing on screen. + model.QueryText = "nothing-is-called-this"; + + machine.FailNext = new InvalidOperationException("The manager went away for a moment."); + await model.RefreshAsync(); + + Assert.Equal(ListFace.Failed, model.Says.Face); + + // The next tick works and nothing on the machine moved. + await model.RefreshAsync(); + + Assert.NotEqual(ListFace.Failed, model.Says.Face); + Assert.DoesNotContain("went away", model.Says.Status, StringComparison.Ordinal); + } + + [Fact] + public void A_name_handed_over_twice_is_one_row() + { + var index = new RowIndex(new SteppedClock()); + var first = Rows.Entry("Spooler", "Print Spooler"); + + index.Absorb([first, Rows.Entry("SPOOLER", "Print Spooler again"), Rows.Entry("W32Time", "Windows Time")]); + + Assert.Equal(2, index.Ordered.Count); + Assert.Equal("Print Spooler", index.Ordered[0].DisplayName); + } +} diff --git a/tests/Bws.Gui.Tests/RunHoldsTheSheetGuards.cs b/tests/Bws.Gui.Tests/RunHoldsTheSheetGuards.cs new file mode 100644 index 0000000..ca57aed --- /dev/null +++ b/tests/Bws.Gui.Tests/RunHoldsTheSheetGuards.cs @@ -0,0 +1,171 @@ +using Bws.Core.Planning; +using Bws.Gui.ViewModels; +using static Bws.Gui.Tests.PlanFixture; + +namespace Bws.Gui.Tests; + +/// +/// A run that is going keeps its sheet, its report and the window's hold on it - G-1 and G-2 of the +/// external stability report, 2026-09-29. +/// +/// Why these are their own file. Until that day Escape, the close mark, a new preview and the +/// details panel could each put the sheet away in the middle of a run. That dropped the plan and +/// lowered the busy flag while the run went on, so the button to carry out came back live, a second +/// run could start beside the first, and the window's close guard and Interrupt then followed +/// whichever run had started last. CarryingGuards drives the states of ONE run - this file asks what +/// every other door of the window does while that run is going. +/// +/// The run is a gate the test holds, through the same seam CarryingGuards uses, so nothing +/// here reaches a service manager. +/// +public sealed class RunHoldsTheSheetGuards +{ + [Fact] + public async Task Escape_and_the_close_mark_leave_a_running_sheet_where_it_is() + { + var gate = new TaskCompletionSource(); + var window = await Ready(carriedOutBy: (_, _, _, _) => gate.Task); + var model = WpfHost.On(() => (MainViewModel)window.DataContext); + var panel = model.Planned; + + Assert.True(await WpfHost.On(() => window.Preview(ActionKind.Stop))); + var plan = panel.Plan; + + var carrying = WpfHost.On(() => window.CarryOut()); + WpfHost.Settled(); + + Assert.True(panel.Busy); + + // A query typed while the run goes on, which Escape used to reach by falling through the + // sheet - and empty under a plan that was still changing the machine. + WpfHost.On(() => model.QueryText = "Spool"); + + Assert.True(WpfHost.On(() => window.Act(Shortcut.Back, out _))); + WpfHost.Settled(); + + Assert.True(panel.Showing); + Assert.Same(plan, panel.Plan); + Assert.True(panel.Busy); + Assert.Equal("Spool", WpfHost.On(() => model.QueryText)); + + // The close mark is grey and says why, rather than being a mark that does nothing. + Assert.False(WpfHost.On(() => window.PlanPanel.PlanCloseButton.IsEnabled)); + Assert.Equal( + Bws.Gui.Texts.Of("gui.plan.blocked.running"), + WpfHost.On(() => window.PlanPanel.PlanCloseButton.ToolTip as string)); + + gate.SetResult(WpfHost.On(() => Ran(panel))); + Assert.True(await carrying); + WpfHost.Settled(); + + // Over, so both doors work again. + Assert.True(WpfHost.On(() => window.PlanPanel.PlanCloseButton.IsEnabled)); + Assert.True(WpfHost.On(() => window.Act(Shortcut.Back, out _))); + Assert.False(panel.Showing); + + WpfHost.On(window.Close); + } + + [Fact] + public async Task Nothing_else_takes_the_sheet_or_starts_a_second_run_while_one_is_going() + { + var gate = new TaskCompletionSource(); + var starts = 0; + var window = await Ready(carriedOutBy: (_, _, _, _) => + { + starts++; + + return gate.Task; + }); + var model = WpfHost.On(() => (MainViewModel)window.DataContext); + var panel = model.Planned; + + Assert.True(await WpfHost.On(() => window.Preview(ActionKind.Stop))); + var plan = panel.Plan; + + var carrying = WpfHost.On(() => window.CarryOut()); + WpfHost.Settled(); + + var refusal = Bws.Gui.Texts.Of("gui.status.couldNotDo", Bws.Gui.Texts.Of("gui.plan.blocked.running")); + + // A new preview, said in the one line a menu item can use - asked straight after, because + // the sheet itself refuses the plan too, and would do it without a word. + Assert.False(await WpfHost.On(() => window.Preview(ActionKind.Start))); + Assert.Equal(refusal, WpfHost.On(() => model.Says.Problem)); + + // The details of a row, and a second press of the button. + Assert.False(WpfHost.On(() => window.OpenDetailsOf(model.Rows[0]))); + Assert.False(await WpfHost.On(() => window.CarryOut())); + + // And the sheet itself, asked directly: it keeps its plan whoever asks. + var another = await WpfHost.On(() => model.PlanAsync(new BulkAction(ActionKind.Start, ["Dnscache"]))); + Assert.False(WpfHost.On(() => panel.Show(another))); + Assert.False(WpfHost.On(() => panel.Hide())); + + Assert.Same(plan, panel.Plan); + Assert.Equal(1, starts); + + // THE CLOSE GUARD STILL KNOWS THIS RUN, which is what a second run used to take from it. + var closed = false; + WpfHost.On(() => + { + window.Closed += (_, _) => closed = true; + window.Close(); + }); + WpfHost.Settled(); + + Assert.False(closed); + + gate.SetResult(WpfHost.On(() => Ran(panel))); + await carrying; + WpfHost.Settled(); + + Assert.True(closed); + } + + /// + /// The window asks the RUN, not only the sheet. A run handed over through the test seam leaves + /// the sheet idle and its button live, so the only thing between a press and a second run is the + /// window's own question about the run it holds. + /// + [Fact] + public async Task A_press_while_the_window_holds_a_run_starts_nothing() + { + var gate = new TaskCompletionSource(); + using var stopping = new CancellationTokenSource(); + var starts = 0; + var window = await Ready(carriedOutBy: (plan, _, _, _) => + { + starts++; + + return Task.FromResult(new BulkRun { Plan = plan, Runs = [] }); + }); + + Assert.True(await WpfHost.On(() => window.Preview(ActionKind.Stop))); + WpfHost.On(() => window.TakeThisAsARun(gate.Task, stopping)); + + Assert.False(await WpfHost.On(() => window.CarryOut())); + Assert.Equal(0, starts); + + gate.SetResult(); + WpfHost.On(window.Close); + } + + [Fact] + public async Task A_run_that_carried_nothing_out_does_not_say_done() + { + var window = await Ready(carriedOutBy: (_, _, _, _) => + Task.FromException(new InvalidOperationException("A plan with problems."))); + + var panel = WpfHost.On(() => ((MainViewModel)window.DataContext).Planned); + + Assert.True(await WpfHost.On(() => window.Preview(ActionKind.Stop))); + Assert.False(await WpfHost.On(() => window.CarryOut())); + WpfHost.Settled(); + + // It used to read "Done. All 0 entries are where you asked." + Assert.Equal(Bws.Gui.Texts.Of("gui.plan.notice.nothingRun"), WpfHost.On(() => panel.Notice)); + + WpfHost.On(window.Close); + } +}