diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4a14904..7cc6106 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,8 +11,32 @@ is not part of this repository.
## [Unreleased]
+### Added
+
+- A plan run from the window can now be left undone, the way a second Ctrl+C leaves one in a
+ terminal. After Interrupt has been pressed and the run is still going three seconds later, a
+ "Leave the rest undone" button appears to its left: it stops watching the step in flight and puts
+ nothing back. It exists for a service that keeps reporting progress and never finishes, which
+ used to leave a window that could only be closed from the task manager. Pressing Interrupt twice
+ never reaches it.
+- When a stop or restart would be refused because other services running on this machine depend on
+ the one you picked, the plan offers "Also stop the 2 in the way" under the sentence naming them.
+ Pressing it builds the same plan again with those services stopped first, as `--dependents` does
+ in a terminal. A force stop refused for the same reason offers the same, and it is the way forward
+ from that sheet. Nothing is carried out until you press the button at the foot of the plan, and a
+ restart as administrator keeps the choice.
+
### Changed
+- Interrupt goes grey once pressed, and the line at the top of the plan says the run was
+ interrupted and is finishing the step in flight. Until now pressing it left no trace on the screen.
+- Closing the window while a plan is being carried out now shows the same thing as Interrupt and
+ says the window closes when the run has ended.
+- A run that was interrupted says so when it ends, and says when nothing was put back, instead of
+ reading like any run that fell short.
+- The line saying which step is running moved under the buttons at the foot of the plan, where it
+ has the whole width and no longer wraps beside them.
+
- A minimised window no longer reads the list of services every second. It reads it again the
moment it is restored, so the list is current as soon as it is on screen. A window started
minimised, from a shortcut set to "Run: Minimized", reads it once and then waits the same way.
diff --git a/README.md b/README.md
index 954e475..eed89c5 100644
--- a/README.md
+++ b/README.md
@@ -258,8 +258,9 @@ line that asks for the same thing, and the way back afterwards.
*asked for*, or *would break otherwise*. Without `--dry-run` those same steps run in that same
order. There is no second code path for the real thing.
- **What comes down with it is in the plan.** The services that would break are steps of their own
- (`--dependents` on the command line, always in the window), and a plan that would take down
- something the machine needs says so in its warnings.
+ when you ask for them - `--dependents` on the command line, and in the window a button under the
+ plan's sentence naming them - and a plan that would take down something the machine needs says so
+ in its warnings.
- **A refusal comes before the button.** A driver, a service the manager will not accept a stop for,
a process the system protects - refused when the plan is built, with the reason, not after a wait.
The window greys the button and says why beside it.
diff --git a/src/Bws.Gui/Carrying.cs b/src/Bws.Gui/Carrying.cs
index 28df8c0..b9a6222 100644
--- a/src/Bws.Gui/Carrying.cs
+++ b/src/Bws.Gui/Carrying.cs
@@ -48,11 +48,17 @@ internal static class Carrying
///
///
/// Asks the run to stop going forward. The steps that give back what earlier steps took are
- /// still carried out, which is the whole difference between this and closing the window.
+ /// still carried out.
+ ///
+ ///
+ /// Asks the run to stop altogether and put nothing back - the command line's second Ctrl+C.
///
- /// Only the first of the command line's two levels is offered. The second - stop and put nothing
- /// back - exists there because the alternative was somebody killing the process, and a killed
- /// process reports nothing. A window has no such person: closing it asks for this one and waits.
+ /// This said "only the first of the two levels is offered" until 2026-09-30, on the
+ /// argument that a window has nobody reaching for the task manager: closing it asked for the
+ /// first level and waited. Package C of the stability report took that argument away - the step
+ /// limit counts time without progress, so an entry reporting progress forever is watched
+ /// forever, and the window then had nothing left to wait for (backlog 497). What guards the
+ /// second level against a press nobody meant is Underway's rule, not this method.
///
///
/// Called before each step that is really attempted, with its place across the whole selection.
@@ -62,7 +68,11 @@ internal static class Carrying
/// often enough to pass a test and throws on a collection.
///
internal static Task Out(
- BulkPlan plan, TimeSpan ceiling, CancellationToken stopping, Action announce) =>
+ BulkPlan plan,
+ TimeSpan ceiling,
+ CancellationToken stopping,
+ CancellationToken abandoning,
+ Action announce) =>
Task.Run(
() =>
{
@@ -73,7 +83,7 @@ internal static Task Out(
// one.
var runner = new BulkRunner(new PlanRunner(new WindowsScmControl(), new SystemClock()));
- return runner.Run(plan, ceiling, stopping, abandonment: default, starting: announce);
+ return runner.Run(plan, ceiling, stopping, abandoning, starting: announce);
},
CancellationToken.None);
}
diff --git a/src/Bws.Gui/MainWindow.Carrying.cs b/src/Bws.Gui/MainWindow.Carrying.cs
index 9bfcb50..8c8946c 100644
--- a/src/Bws.Gui/MainWindow.Carrying.cs
+++ b/src/Bws.Gui/MainWindow.Carrying.cs
@@ -51,6 +51,12 @@ public partial class MainWindow
/// Asks the run in progress to stop before its next step. Null when none is.
private CancellationTokenSource? _stopping;
+ ///
+ /// Asks the run in progress to stop altogether and put nothing back - the second level, since
+ /// 2026-09-30 (backlog 497). Null when none is. Cancelled only when Underway agrees.
+ ///
+ private CancellationTokenSource? _abandoning;
+
///
/// How a plan is carried out: , unless somebody handed this window
/// another way.
@@ -72,7 +78,7 @@ public partial class MainWindow
/// Init rather than settable, so a run cannot be swapped out from under a window that
/// is already using one.
///
- internal Func, Task> CarriedOutBy
+ internal Func, Task> CarriedOutBy
{
get;
init;
@@ -106,15 +112,18 @@ internal async Task CarryOut()
}
using var stopping = new CancellationTokenSource();
+ using var abandoning = new CancellationTokenSource();
_stopping = stopping;
+ _abandoning = abandoning;
var watching = new DispatcherTimer(DispatcherPriority.Background)
{
Interval = TimeSpan.FromSeconds(1)
};
- watching.Tick += (_, _) => _model.Planned.Tick();
+ // The same tick moves the clock on the line and offers the second level once it is due.
+ watching.Tick += (_, _) => _model.Planned.Underway.Tick();
try
{
@@ -139,7 +148,7 @@ internal async Task CarryOut()
// thread reach a bound property here rather than there. Built per run rather than kept,
// because building it anywhere else would capture whatever thread happened to be there.
var announce = new Progress<(PlanStep Step, int Number)>(
- what => _model.Planned.Announce(what.Step, what.Number));
+ what => _model.Planned.Underway.Announce(what.Step, what.Number));
// THE CEILING COMES FROM THE PANEL RATHER THAN FROM A CONSTANT, SINCE 2026-09-09 -
// backlog 330. Read here, at the press, rather than held anywhere: the box is on the
@@ -148,6 +157,7 @@ internal async Task CarryOut()
plan,
TimeSpan.FromSeconds(_model.Planned.Waiting),
stopping.Token,
+ abandoning.Token,
(step, number) => ((IProgress<(PlanStep, int)>)announce).Report((step, number)));
_running = running;
@@ -171,6 +181,7 @@ internal async Task CarryOut()
_running = null;
_stopping = null;
+ _abandoning = null;
// AND THE PANEL STOPS SAYING A RUN IS UNDER WAY, WHICH UNTIL 2026-09-03 ONLY THE TWO
// ENDINGS ABOVE DID - backlog 298. Both of them go through Finished, so anything else
@@ -239,8 +250,16 @@ internal void TakeThisAsARun(Task running, CancellationTokenSource stopping)
///
/// So the close is refused ONCE, the run is asked to stop, and the close is asked for again
/// when it has. The steps that give back what earlier steps took still run, because that is what
- /// asking to stop means here - the same first level the command line offers, and deliberately
- /// not its second.
+ /// asking to stop means here - the same first level the command line offers.
+ ///
+ /// AND THE SHEET SAYS SO, SINCE 2026-09-30 - backlog 497. Until then this asked the first
+ /// level and waited with nothing on the screen changing, and since the step limit counts time
+ /// without progress, an entry reporting progress forever made that wait endless - the window
+ /// could only be ended from the task manager. The close now goes through the same door as the
+ /// Interrupt button, so the sheet shows the first level asked, says the window will close when
+ /// the run has ended, and offers the second level on the sheet after the same wait. A second
+ /// reach for the corner is not the second level - that is the same double-press Underway guards
+ /// against on the button.
///
protected override async void OnClosing(System.ComponentModel.CancelEventArgs e)
{
@@ -260,6 +279,7 @@ protected override async void OnClosing(System.ComponentModel.CancelEventArgs e)
// Skipping it here is the ordinary shape of a cancelled close rather than a shortcut: the
// event means "this window is closing", and after this line it is not.
e.Cancel = true;
+ _model.Planned.Underway.Interrupt(closing: true);
_stopping?.Cancel();
await running.ConfigureAwait(true);
@@ -278,6 +298,28 @@ protected override async void OnClosing(System.ComponentModel.CancelEventArgs e)
/// Reached from two files, that story would be told in one and relied on in the other.
///
/// Not async: cancelling is instant, and the waiting belongs to whoever is awaiting the run.
+ ///
+ /// Both levels through one door since 2026-09-30 (backlog 497), and a flag rather than a
+ /// second method because the window stands on the ceiling of methods a type may have. The first
+ /// level is asked whatever the sheet says - the test seam hands this window runs with the sheet
+ /// idle, and stopping a run must never depend on a picture of it. The second is asked only when
+ /// Underway agrees, which is the whole of what keeps a press nobody meant from leaving a machine
+ /// with nothing put back.
///
- internal void AskTheRunToStop() => _stopping?.Cancel();
+ /// The second level: stop watching and put nothing back.
+ internal void AskTheRunToStop(bool leaveTheRestUndone = false)
+ {
+ if (leaveTheRestUndone)
+ {
+ if (_model.Planned.Underway.Abandon())
+ {
+ _abandoning?.Cancel();
+ }
+
+ return;
+ }
+
+ _model.Planned.Underway.Interrupt(closing: false);
+ _stopping?.Cancel();
+ }
}
diff --git a/src/Bws.Gui/MainWindow.HandOver.cs b/src/Bws.Gui/MainWindow.HandOver.cs
index 4baddc3..8a8ebeb 100644
--- a/src/Bws.Gui/MainWindow.HandOver.cs
+++ b/src/Bws.Gui/MainWindow.HandOver.cs
@@ -18,7 +18,7 @@ public partial class MainWindow
/// The plan that is open, as it was asked for - remembered at the moment it opens, because the
/// sheet itself keeps the answer and not the question.
///
- private (ActionKind Kind, StartSetting? To, bool AlsoStop)? _asked;
+ private (ActionKind Kind, StartSetting? To, bool AlsoStop, bool Dependents)? _asked;
///
/// The first reading, and everything that has to wait for it. Out of the constructor since
@@ -64,7 +64,8 @@ private async Task FirstLook()
Picked = [.. PickedRows().Select(row => row.ServiceName)],
Asked = _model.Planned.Showing ? _asked?.Kind : null,
To = _model.Planned.Showing ? _asked?.To : null,
- AlsoStop = _model.Planned.Showing && _asked?.AlsoStop == true
+ AlsoStop = _model.Planned.Showing && _asked?.AlsoStop == true,
+ Dependents = _model.Planned.Showing && _asked?.Dependents == true
};
///
@@ -114,7 +115,7 @@ internal async Task TakeOverAsync(HandOver? carried, bool refused)
if (found > 0 && carried.Asked is { } kind)
{
- await Preview(kind, carried.To, carried.AlsoStop).ConfigureAwait(true);
+ await Preview(kind, carried.To, carried.AlsoStop, carried.Dependents).ConfigureAwait(true);
}
}
diff --git a/src/Bws.Gui/MainWindow.Menu.cs b/src/Bws.Gui/MainWindow.Menu.cs
index 5216504..53955ed 100644
--- a/src/Bws.Gui/MainWindow.Menu.cs
+++ b/src/Bws.Gui/MainWindow.Menu.cs
@@ -194,8 +194,9 @@ internal bool OpenDetailsOfPointed() =>
/// The cascade is NOT asked for, which matches the command line's own default and is a safety
/// property rather than a preference. Asking to stop one service is not asking to stop seven.
/// What the plan does instead is name the ones in the way, in a warning, so `C2`'s promise about
- /// saying what would be dragged in is kept without quietly widening what was asked. There is no
- /// control for turning it on yet and that is open rather than decided.
+ /// saying what would be dragged in is kept without quietly widening what was asked. Since
+ /// 2026-09-30 the sheet offers to ask again with them - the owner's decision on W-4 of the
+ /// stability report - and that offer arrives here as , never a menu.
///
/// What was asked for, in the words a person used.
///
@@ -208,7 +209,10 @@ internal bool OpenDetailsOfPointed() =>
/// Whether the setting carries a stop - the offer under "keeps running" was taken, which asks
/// this same question again over the same rows with this one word changed.
///
- internal async Task Preview(ActionKind kind, StartSetting? to = null, bool alsoStop = false)
+ ///
+ /// Whether the running dependants in the way are stopped first - the offer under them was taken.
+ ///
+ internal async Task Preview(ActionKind kind, StartSetting? to = null, bool alsoStop = false, bool dependents = 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
@@ -269,7 +273,8 @@ internal async Task Preview(ActionKind kind, StartSetting? to = null, bool
// MainViewModel.PlanAsync. The whole listing selected and asked to stop was measured at
// 224-240 ms, all of it round trips to the manager, and the owner's decision was that a
// window not answering for that long is too much.
- var plan = await _model.PlanAsync(new BulkAction(kind, names, To: to, AlsoStop: alsoStop)).ConfigureAwait(true);
+ var plan = await _model.PlanAsync(
+ new BulkAction(kind, names, IncludeDependents: dependents, To: to, AlsoStop: alsoStop)).ConfigureAwait(true);
if (asked != _previews)
{
@@ -290,7 +295,7 @@ internal async Task Preview(ActionKind kind, StartSetting? to = null, bool
if (shown)
{
// The question behind the open sheet, for a restart as administrator to ask again.
- _asked = (kind, to, alsoStop);
+ _asked = (kind, to, alsoStop, dependents);
PlanPanel.TakeTheKeyboard();
}
@@ -299,25 +304,78 @@ internal async Task Preview(ActionKind kind, StartSetting? to = null, bool
}
///
- /// Takes up the offer under "keeps running": the same ask over the same rows, with the stop
- /// riding on it - so the sheet on the screen becomes the same sheet with a second step.
+ /// Takes up an offer under a sentence of the plan: the same ask over the same rows with one word
+ /// changed - so the sheet on the screen becomes the same sheet with more steps.
///
/// THE SAME SHEET HERE, WHERE THE OFFER UNDER A FAILURE OPENS A NEW ONE, and the difference
/// is the one names. That sheet is a record of a run and may not grow a
/// second question. This one has not run - it is still the question, and taking the offer
- /// changes the question before anybody answers it. PlanWarningLine offers nothing on a record.
+ /// changes the question before anybody answers it. PlanSentence offers nothing on a record.
///
- /// Only from the shape the offer stands under: a start type plan setting Disabled that
- /// does not already stop. Anything else answers false and opens nothing.
+ /// Only from the shape each offer stands under. The stop rides on a start type plan
+ /// setting Disabled that does not already stop. The dependants join any other plan that did not
+ /// already ask for them - PlanSentence decides which plans offer it, and a startup setting is
+ /// not one of them. Anything else answers false and opens nothing.
///
/// And only while the sheet still offers it - asked of the same lines that draw the
/// button, so the door and the button cannot disagree. Until review of PR #17 the shape was
/// the only check, and a press during a run rebuilt the plan under the run still going.
+ ///
+ /// One door for both since 2026-09-30, and that is the ceiling of methods on this window
+ /// asking rather than tidiness: a second offer would otherwise have been a second method.
+ ///
+ /// THE DEPENDANTS ARE ASKED OVER THE NAMES OF THE PLAN ON THE SHEET, NOT OVER THE PICKED ROWS
+ /// - found by the second analysis of its design. A forcing sheet opened from a failure
+ /// () is about the entry that failed, which can be a dependant of the row that
+ /// was picked. Asked again over the picked rows, the offer would have built a plan for a different
+ /// entry than the one on the screen - the preview and the question parting company, which is the
+ /// worst fault this product can have. The first offer keeps going through Preview: a startup
+ /// setting is only ever opened from the picked rows.
///
- internal async Task AlsoStop() =>
- _asked is { Kind: ActionKind.SetStartType, To: StartSetting.Disabled, AlsoStop: false }
- && _model.Planned.Warnings.Any(line => line.HasOffer)
- && await Preview(ActionKind.SetStartType, StartSetting.Disabled, alsoStop: true).ConfigureAwait(true);
+ internal async Task TakeTheOffer(PlanOffer offer)
+ {
+ if (_asked is not { } asked
+ || _model.Planned.Plan is not { } shown
+ || !_model.Planned.Warnings.Concat(_model.Planned.Problems).Any(line => line.HasOffer && line.Offer == offer))
+ {
+ return false;
+ }
+
+ if (offer == PlanOffer.AlsoStop)
+ {
+ return asked is { Kind: ActionKind.SetStartType, To: StartSetting.Disabled, AlsoStop: false }
+ && await Preview(ActionKind.SetStartType, StartSetting.Disabled, alsoStop: true).ConfigureAwait(true);
+ }
+
+ if (offer != PlanOffer.InTheWay || asked.Dependents || shown.Action.IncludeDependents)
+ {
+ return false;
+ }
+
+ var again = shown.Action with { IncludeDependents = true };
+ var ticket = ++_previews;
+ var plan = await _model.PlanAsync(again).ConfigureAwait(true);
+
+ // The same counter every preview goes through, for the reason Force gives.
+ if (ticket != _previews)
+ {
+ return false;
+ }
+
+ var shownAs = again.ServiceNames.Count != 1 ? null : _model.Rows
+ .FirstOrDefault(row => string.Equals(row.ServiceName, again.ServiceNames[0], StringComparison.OrdinalIgnoreCase))
+ ?.DisplayName;
+
+ if (!_model.Planned.Show(plan, shownAs))
+ {
+ return false;
+ }
+
+ _asked = asked with { Dependents = true };
+ PlanPanel.TakeTheKeyboard();
+
+ return true;
+ }
///
/// Takes up the offer under a failure: closes the sheet reporting it and opens a new one
@@ -379,7 +437,7 @@ internal async Task Force(PlanFailure failure)
if (shown)
{
- _asked = (offer.Kind, null, false);
+ _asked = (offer.Kind, null, false, false);
// WHERE THE KEYBOARD LANDS IS PART OF THIS SLICE RATHER THAN A COURTESY. This is the
// one sheet in the window whose main button ends a process, so Enter arriving on it
diff --git a/src/Bws.Gui/MainWindow.xaml.cs b/src/Bws.Gui/MainWindow.xaml.cs
index 30b59cd..0489385 100644
--- a/src/Bws.Gui/MainWindow.xaml.cs
+++ b/src/Bws.Gui/MainWindow.xaml.cs
@@ -294,6 +294,9 @@ private void ListenToThePlanSheet()
// with two stories. The wiring stays here, where every other event of this panel is wired.
PlanPanel.InterruptRequest += (_, _) => AskTheRunToStop();
+ // The second level through the same door - which asks Underway before it cancels anything.
+ PlanPanel.AbandonRequest += (_, _) => AskTheRunToStop(leaveTheRestUndone: true);
+
// The clipboard rather than the panel, for the reason beside CopyRequest over there: only
// the window can say a copy was refused, because only the window owns the status line.
PlanPanel.CopyRequest += (_, asked) => Put(asked.Command);
@@ -303,10 +306,11 @@ private void ListenToThePlanSheet()
// asks the manager and that moved off this thread on 2026-09-03.
PlanPanel.ForceRequest += async (_, asked) => await Force(asked.Failure).ConfigureAwait(true);
- // THE OFFER UNDER "KEEPS RUNNING" - spec C4, the owner's variant O1 of 2026-09-24. The same
- // question asked again with the stop riding on it, so the sheet that answers is this one
- // with a second step rather than a second sheet somebody has to remember to open.
- PlanPanel.AlsoStopRequest += async (_, _) => await AlsoStop().ConfigureAwait(true);
+ // THE OFFERS UNDER A SENTENCE OF THE PLAN - "keeps running" (spec C4, the owner's variant O1
+ // of 2026-09-24) and dependants in the way (W-4, 2026-09-30). The same question asked again
+ // with one word changed, so the sheet that answers is this one rather than a second sheet
+ // somebody has to remember to open.
+ PlanPanel.OfferRequest += async (_, asked) => await TakeTheOffer(asked.Offer).ConfigureAwait(true);
}
///
diff --git a/src/Bws.Gui/PlanFooter.xaml b/src/Bws.Gui/PlanFooter.xaml
index 3df4145..925d21c 100644
--- a/src/Bws.Gui/PlanFooter.xaml
+++ b/src/Bws.Gui/PlanFooter.xaml
@@ -37,6 +37,10 @@
safety: a verb that changes under a finger turns a double click into an instruction nobody
gave. It appears to the LEFT, so the rectangle somebody aimed at never becomes something
else - it becomes disabled.
+
+ THE SECOND WAY OF STOPPING FOLLOWS THE SAME RULE ONE STEP FURTHER, since 2026-09-30 (backlog
+ 497): it appears to the left of Interrupt, only after Interrupt was pressed and has gone grey,
+ and only once the run has kept going for three seconds more. Underway says why each half.
-->
+
+
@@ -189,7 +199,7 @@
narrow paragraph beside two buttons.
-->
+
+
+
+
-
internal event EventHandler? InterruptRequest;
+ ///
+ /// Somebody asked a run already interrupted to stop watching and put nothing back - the second
+ /// level, since 2026-09-30 (backlog 497). Whether it may is Underway's answer, not this control's.
+ ///
+ internal event EventHandler? AbandonRequest;
+
public PlanFooter() => InitializeComponent();
///
@@ -82,4 +88,7 @@ private void CarryOutRequested(object sender, RoutedEventArgs e) =>
private void InterruptRequested(object sender, RoutedEventArgs e) =>
InterruptRequest?.Invoke(this, EventArgs.Empty);
+
+ private void AbandonRequested(object sender, RoutedEventArgs e) =>
+ AbandonRequest?.Invoke(this, EventArgs.Empty);
}
diff --git a/src/Bws.Gui/PlanView.xaml b/src/Bws.Gui/PlanView.xaml
index b117462..35cd052 100644
--- a/src/Bws.Gui/PlanView.xaml
+++ b/src/Bws.Gui/PlanView.xaml
@@ -259,7 +259,7 @@
+ ItemTemplate="{StaticResource PlanSentenceTemplate}" />
? ForceRequest;
///
- /// Somebody took the offer under "keeps running": the same ask again, with the stop as a second
- /// step. An event for the reason the one above gives - building a plan means asking the manager
+ /// Somebody asked a run already interrupted to stop watching and put nothing back. Passed on
+ /// unchanged, like the two presses above it.
+ ///
+ internal event EventHandler? AbandonRequest;
+
+ ///
+ /// Somebody took an offer under a sentence of the plan, and it carries which: the stop riding on
+ /// a startup setting, or the dependants in the way stopped first. The same ask again with one word
+ /// changed. An event for the reason the ones above give - building a plan means asking the manager
/// off this thread, and that lives where every other preview is opened.
///
- internal event EventHandler? AlsoStopRequest;
+ internal event EventHandler? OfferRequest;
///
/// Somebody asked for one of the terminal commands, and it carries which one.
@@ -74,22 +81,33 @@ public PlanView()
// outside these two lines had to learn that the ask lives somewhere else now.
Footer.CarryOutRequest += (_, _) => CarryOutRequest?.Invoke(this, EventArgs.Empty);
Footer.InterruptRequest += (_, _) => InterruptRequest?.Invoke(this, EventArgs.Empty);
+ Footer.AbandonRequest += (_, _) => AbandonRequest?.Invoke(this, EventArgs.Empty);
- // CAUGHT ON THE LIST RATHER THAN NAMED IN THE MARKUP, which is the other way this sheet
+ // CAUGHT ON THE LISTS RATHER THAN NAMED IN THE MARKUP, which is the other way this sheet
// catches a press from a template - the failures name theirs in PlanView.xaml. This file's
// markup stands at the size ceiling, so the handler is wired here and the markup carries
- // only a name. Only a line that carries the offer has a button, so any press here is it.
- WarningList.AddHandler(ButtonBase.ClickEvent, new RoutedEventHandler(AlsoStopRequested));
+ // only a name. Both lists draw PlanSentence with one template since 2026-09-30, and the
+ // line under the finger says which offer it is.
+ WarningList.AddHandler(ButtonBase.ClickEvent, new RoutedEventHandler(OfferRequested));
+ ProblemList.AddHandler(ButtonBase.ClickEvent, new RoutedEventHandler(OfferRequested));
}
- private void AlsoStopRequested(object sender, RoutedEventArgs e)
+ private void OfferRequested(object sender, RoutedEventArgs e)
{
- if (e.OriginalSource is not Button { Tag: PlanWarningLine { HasOffer: true } })
+ if (e.OriginalSource is not Button { Tag: PlanSentence { HasOffer: true } line })
{
return;
}
- AlsoStopRequest?.Invoke(this, EventArgs.Empty);
+ OfferRequest?.Invoke(this, new OfferAsked(line.Offer));
+ }
+
+ ///
+ /// Which offer was taken. A type for one value for the reason gives.
+ ///
+ internal sealed class OfferAsked(PlanOffer offer) : EventArgs
+ {
+ internal PlanOffer Offer { get; } = offer;
}
private void CloseRequested(object sender, RoutedEventArgs e) => Dismiss();
@@ -356,7 +374,7 @@ internal void TakeTheKeyboard() =>
[.. StepList.Items.OfType().Select(line => line.Text)];
/// The entries that got no plan, as they reach the screen.
- internal IReadOnlyList ProblemLines => [.. ProblemList.Items.OfType()];
+ internal IReadOnlyList ProblemLines => [.. ProblemList.Items.OfType().Select(line => line.Text)];
///
/// The command lines, as they reach the screen - one box each up to twenty, one field past that
@@ -395,14 +413,16 @@ private static IReadOnlyList Shown(ItemsControl list, TextBox field) =>
/// What is worth knowing, as it reaches the screen.
internal IReadOnlyList WarningLines =>
- [.. WarningList.Items.OfType().Select(line => line.Text)];
+ [.. WarningList.Items.OfType().Select(line => line.Text)];
///
- /// The offer under those sentences, as it reaches the screen - read off the items for the reason
- /// gives about the failures.
+ /// The offers under the sentences of both lists - what is worth knowing and what was refused - as
+ /// they reach the screen, read off the items for the reason gives about
+ /// the failures. Named for the first offer until 2026-09-30, when the second arrived.
///
- internal IReadOnlyList AlsoStopLines =>
- [.. WarningList.Items.OfType().Where(line => line.HasOffer).Select(line => line.Label)];
+ internal IReadOnlyList SentenceOffers =>
+ [.. WarningList.Items.OfType().Concat(ProblemList.Items.OfType())
+ .Where(line => line.HasOffer).Select(line => line.Label)];
///
/// Whether the refusals section is on the screen at all, heading included.
diff --git a/src/Bws.Gui/Resources/gui.en.json b/src/Bws.Gui/Resources/gui.en.json
index 8f78441..b06ca53 100644
--- a/src/Bws.Gui/Resources/gui.en.json
+++ b/src/Bws.Gui/Resources/gui.en.json
@@ -236,6 +236,13 @@
"gui.plan.notice.partly.one": "The entry is not where you asked. What did not is below.",
"gui.plan.notice.partly.many": "{0} of {1} entries are where you asked. What did not is below.",
"gui.plan.notice.nothingRun": "Nothing was carried out. The line at the bottom of the window says why.",
+ "gui.plan.notice.interrupting": "Interrupted. Finishing the step in flight, then putting back what this run took down.",
+ "gui.plan.notice.abandoning": "Leaving the rest undone. Nothing is put back, and the run ends as soon as Windows answers what it is being asked now.",
+ "gui.plan.notice.closing": "The window closes when the run has ended.",
+ "gui.plan.notice.interrupted.one": "Interrupted, so the entry is not where you asked. The steps after that point were not tried.",
+ "gui.plan.notice.interrupted.many": "Interrupted, so {0} of {1} entries are where you asked. The steps after that point were not tried.",
+ "gui.plan.notice.leftAsItWas.one": "Interrupted and left as it was, so the entry is not where you asked. What this run took down was not put back.",
+ "gui.plan.notice.leftAsItWas.many": "Interrupted and left as it was, so {0} of {1} entries are where you asked. What this run took down was not put back.",
"gui.plan.steps": "In this order",
"gui.plan.step": "{0}. {1} {2} {3}",
@@ -337,6 +344,9 @@
"gui.plan.offer.alsoStop.one": "Also stop it",
"gui.plan.offer.alsoStop.many": "Also stop the {0} that are running",
"gui.plan.offer.alsoStop.hint": "Add a step to this plan that stops each running entry after its startup type is set. Nothing happens until you press the button at the foot.",
+ "gui.plan.offer.inTheWay.one": "Also stop the one in the way",
+ "gui.plan.offer.inTheWay.many": "Also stop the {0} in the way",
+ "gui.plan.offer.inTheWay.hint": "Build this plan again with the running entries that depend on it stopped first, as --dependents does from a terminal. Nothing happens until you press the button at the foot.",
"gui.plan.confirm.type": "Type {0} to confirm",
"gui.plan.confirm.hint": "The line above says what this would do. Typing the name out is how you say you have read it.",
"gui.plan.confirm.selection": "A selection cannot do this to the entries named above. Deselect them, or do it to each one on its own.",
@@ -344,6 +354,9 @@
"gui.plan.blocked.notSeconds": "The box above is not a number of seconds. Type a whole number, at least 1, to turn this on.",
"gui.plan.interrupt": "Interrupt",
"gui.plan.interrupt.hint": "Stop before the next step. Anything already taken down is still put back.",
+ "gui.plan.interrupt.asked": "Asked. The step in flight is watched to its end, then what this run took down is put back.",
+ "gui.plan.abandon": "Leave the rest undone",
+ "gui.plan.abandon.hint": "Stop watching the step in flight and put nothing back. What this run took down stays down, and the sheet says how to put it back.",
"gui.plan.progress": "Step {0} of {1}: {2} {3}",
"gui.plan.progress.waiting": "{0} - {1} s so far, given up on after {2} s with no progress",
"gui.plan.waiting.label": "Wait up to",
diff --git a/src/Bws.Gui/Themes/PlanLines.xaml b/src/Bws.Gui/Themes/PlanLines.xaml
index 16a8256..f69524c 100644
--- a/src/Bws.Gui/Themes/PlanLines.xaml
+++ b/src/Bws.Gui/Themes/PlanLines.xaml
@@ -74,27 +74,11 @@
-
-
-
-
+
diff --git a/src/Bws.Gui/ViewModels/Catalogue.Runs.cs b/src/Bws.Gui/ViewModels/Catalogue.Runs.cs
new file mode 100644
index 0000000..246ba58
--- /dev/null
+++ b/src/Bws.Gui/ViewModels/Catalogue.Runs.cs
@@ -0,0 +1,77 @@
+using Bws.Core;
+using Bws.Core.Planning;
+
+namespace Bws.Gui.ViewModels;
+
+///
+/// The foot of the plan sheet while a run is going - the three states the catalogue could not show
+/// until 2026-09-30.
+///
+/// Why they are here at all. Until that day the foot of the sheet appeared in the catalogue
+/// only BEFORE a run, and the two buttons that exist only during one - Interrupt, and since that day
+/// the second way of stopping a run (backlog 497) - were on no sheet anybody could look at. GUI rule 4
+/// asks for every component in every state, and these are states nothing but a run reaches.
+///
+/// Its own file, because Catalogue.Views.cs stands a score of lines under the ceiling on file
+/// length, and this is one subject: a sheet with a run under way, told what a runner would tell
+/// it, and never a runner. Nothing here can change a machine - there is no writer in this assembly
+/// outside Carrying.cs, and PlanOnlyGuards holds that.
+///
+public static partial class Catalogue
+{
+ ///
+ /// A stop of Spooler under way: running, interrupted and waiting on its step, and interrupted long
+ /// enough for the second level to be offered.
+ ///
+ private static async Task<(MainViewModel Running, MainViewModel Interrupted, MainViewModel Abandonable)> PrepareRunsAsync()
+ {
+ var models = new List();
+
+ for (var state = 0; state < 3; state++)
+ {
+ var clock = new Wound();
+ var model = new MainViewModel(new Frozen(Specimens()), clock) { Planned = new Planned { Clock = clock } };
+
+ await model.LoadAsync().ConfigureAwait(false);
+ await ShowPlanAsync(model, ActionKind.Stop, "Spooler").ConfigureAwait(false);
+
+ model.Planned.Starting();
+ model.Planned.Underway.Announce(model.Planned.Plan!.Steps.First(), 1);
+
+ // The clock on the line reads as a step that has been going a while - which is what a
+ // person sees on a sheet worth interrupting.
+ clock.Wait(TimeSpan.FromSeconds(42));
+ model.Planned.Underway.Tick();
+
+ if (state > 0)
+ {
+ model.Planned.Underway.Interrupt(closing: state == 2);
+ }
+
+ if (state == 2)
+ {
+ clock.Wait(Underway.ArmsAfter);
+ model.Planned.Underway.Tick();
+ }
+
+ models.Add(model);
+ }
+
+ return (models[0], models[1], models[2]);
+ }
+
+ ///
+ /// A clock that waits by moving on - the one way to show a state a sheet reaches only after
+ /// three seconds, without the sheet taking three seconds to build.
+ ///
+ private sealed class Wound : IClock
+ {
+ private TimeSpan _ahead;
+
+ public DateTimeOffset Now => DateTimeOffset.UtcNow + _ahead;
+
+ public TimeSpan Elapsed => System.Diagnostics.Stopwatch.GetElapsedTime(0) + _ahead;
+
+ public void Wait(TimeSpan duration) => _ahead += duration;
+ }
+}
diff --git a/src/Bws.Gui/ViewModels/Catalogue.Specimens.cs b/src/Bws.Gui/ViewModels/Catalogue.Specimens.cs
index 900a7b5..d1435eb 100644
--- a/src/Bws.Gui/ViewModels/Catalogue.Specimens.cs
+++ b/src/Bws.Gui/ViewModels/Catalogue.Specimens.cs
@@ -171,14 +171,18 @@ private static (object Normal, object Extreme)? TemplateSpecimens(string key)
"PlanLineTemplate" => (
new PlanLine(new NamedSentence("1. stop ", "Spooler", " (asked for)"), asked: true),
new PlanLine(new NamedSentence("2. stop ", LongName + " display name", ", which came along"), asked: false)),
- "PlanSentenceTemplate" => ("Stopping this will stop the two entries that depend on it as well.", LongDescription),
"PlanFailureTemplate" => (new PlanFailure("Windows refused to stop it: access is denied (5).", offer: null), new PlanFailure(LongDescription, offer: null)),
- // The extreme carries the offer, because the button under a long sentence is the one
- // shape of this template that can go wrong - it has to stay under its own sentence.
- "PlanWarningTemplate" => (
- PlanWarningLine.Of([new PlanWarning(PlanWarningKind.ReturnsAfterReboot, "Spooler", [])], offering: false)[0],
- PlanWarningLine.Of([new PlanWarning(PlanWarningKind.KeepsRunning, LongName, [])], offering: true)[0]),
+ // One template for the warnings and the refusals since 2026-09-30. The extreme carries
+ // an offer under a long sentence naming long entries, because the button under such a
+ // sentence is the one shape of this template that can go wrong - it has to stay under
+ // its own sentence, and its label counts the names.
+ "PlanSentenceTemplate" => (
+ PlanSentence.Of([new PlanWarning(PlanWarningKind.ReturnsAfterReboot, "Spooler", [])], ActionKind.Stop, offering: false)[0],
+ PlanSentence.Of(
+ [new PlanWarning(PlanWarningKind.DependentsInTheWay, LongName, [LongName + "One", LongName + "Two", LongName + "Three"])],
+ ActionKind.Stop,
+ offering: true)[0]),
"PlanCommandTemplate" => ("bws stop Spooler --dry-run", "bws stop " + LongName + " --dependents --timeout 90 --json --timing --follow-network"),
_ => null
diff --git a/src/Bws.Gui/ViewModels/Catalogue.Views.cs b/src/Bws.Gui/ViewModels/Catalogue.Views.cs
index 7986735..0303660 100644
--- a/src/Bws.Gui/ViewModels/Catalogue.Views.cs
+++ b/src/Bws.Gui/ViewModels/Catalogue.Views.cs
@@ -92,6 +92,15 @@ internal Prepared(Stalling stalling, Task stillReading)
internal MainViewModel RefusingSelection { get; init; } = null!;
+ ///
+ /// A run under way, the same run interrupted, and interrupted long enough for the second level
+ /// to be offered - since 2026-09-30 (backlog 497). Catalogue.Runs.cs says why they exist.
+ ///
+ /// One member for the three, because the shape guard counted this type among those standing
+ /// near the ceiling of fields when they arrived as three.
+ ///
+ internal (MainViewModel Running, MainViewModel Interrupted, MainViewModel Abandonable) Runs { get; init; }
+
///
/// A query with a mistake in it, and one whose answer has to be qualified - the two things
/// the line under the search box says, since 2026-09-23 (UX-GUI-002 and 009).
@@ -132,6 +141,7 @@ public static async Task PrepareViewsAsync()
var (mistaken, qualified, unelevated) = await PrepareSentencesAsync(clock).ConfigureAwait(false);
var panels = await PreparePanelsAsync(clock).ConfigureAwait(false);
var (askingForName, refusingSelection) = await PrepareHeavyAsksAsync(clock).ConfigureAwait(false);
+ var runs = await PrepareRunsAsync().ConfigureAwait(false);
// THE LOADING ONE IS STARTED AND NOT AWAITED HERE. Its read waits on the gate until the
// sheet is done, which is the whole point of it: the model is in the state between asking
@@ -181,6 +191,7 @@ public static async Task PrepareViewsAsync()
WithBulkPlan = plannedInBulk,
AskingForName = askingForName,
RefusingSelection = refusingSelection,
+ Runs = runs,
Mistaken = mistaken,
Qualified = qualified,
Unelevated = unelevated
@@ -419,6 +430,12 @@ private static Built[] PlanViews(Prepared ready) =>
View(() => new PlanFooter(),
data: ready.WithPlan.Planned, wrong: ready.WithRefusedPlan.Planned, extreme: ready.WithBulkPlan.Planned),
+ // The same foot while a run goes, since 2026-09-30 (backlog 497): data is a run under way,
+ // loading is the same run interrupted and waiting on its step, extreme is the second level
+ // offered with the window waiting to close. Catalogue.Runs.cs builds them.
+ View(() => new PlanFooter(), key: nameof(PlanFooter) + " while a run goes",
+ data: ready.Runs.Running.Planned, loading: ready.Runs.Interrupted.Planned, extreme: ready.Runs.Abandonable.Planned),
+
// The heavy ask on its own, since 2026-09-29 (backlog 475): data is one critical entry
// asking for its name, wrong is a selection holding it, refused with no box.
View(() => new PlanConfirmation(),
diff --git a/src/Bws.Gui/ViewModels/HandOver.cs b/src/Bws.Gui/ViewModels/HandOver.cs
index 6bff684..bc97a32 100644
--- a/src/Bws.Gui/ViewModels/HandOver.cs
+++ b/src/Bws.Gui/ViewModels/HandOver.cs
@@ -39,7 +39,12 @@ internal sealed record HandOver
/// Longer than this and the argument is not one this program wrote. Also the budget the writer keeps under.
internal const int LongestArgument = 8192;
- private const int Version = 1;
+ ///
+ /// 2 since 2026-09-30, when arrived. A copy of the program without that
+ /// field would read a newer hand-over and quietly drop the dependants from the plan it asks
+ /// again - a different plan from the one on the screen - so a mismatched pair refuses instead.
+ ///
+ private const int Version = 2;
private const int LongestQuery = 4096;
private const int LongestName = 256;
@@ -70,6 +75,12 @@ internal sealed record HandOver
///
public bool AlsoStop { get; init; }
+ ///
+ /// Whether that plan also stopped the running dependants first - the offer under them had been
+ /// taken (W-4, 2026-09-30). Carried for the reason is.
+ ///
+ public bool Dependents { get; init; }
+
///
/// Whether the picked rows were too many to fit, so they and the plan over them were left out -
/// the new window says so rather than showing a selection that looks complete (rule 8).
@@ -86,7 +97,7 @@ internal string Encode()
return whole.Length <= LongestArgument
? whole
- : Write(this with { Picked = [], Asked = null, To = null, AlsoStop = false, PickedLeftBehind = true });
+ : Write(this with { Picked = [], Asked = null, To = null, AlsoStop = false, Dependents = false, PickedLeftBehind = true });
}
///
@@ -128,6 +139,7 @@ private static string Write(HandOver handOver)
Asked = handOver.Asked?.ToString(),
To = handOver.To?.ToString(),
Stop = handOver.AlsoStop,
+ Deps = handOver.Dependents,
Left = handOver.PickedLeftBehind
};
@@ -177,6 +189,7 @@ private static string Write(HandOver handOver)
Asked = picked.Length > 0 ? asked : null,
To = picked.Length > 0 ? to : null,
AlsoStop = picked.Length > 0 && wire.Stop,
+ Dependents = picked.Length > 0 && wire.Deps,
PickedLeftBehind = wire.Left
};
}
@@ -185,6 +198,8 @@ private static string Write(HandOver handOver)
/// The plan that was open, checked: a kind that exists, a setting exactly when the kind is a
/// start type plan - such a plan always has one and no other plan ever does - and a stop riding
/// on it only beside Disabled, which is the one shape this window can have put on the screen.
+ /// The dependants only beside the four kinds that can take them (EquivalentCommand renders
+ /// --dependents for exactly those), never a start and never a startup setting.
///
private static bool Plan(Wire wire, out ActionKind? asked, out StartSetting? to)
{
@@ -193,7 +208,7 @@ private static bool Plan(Wire wire, out ActionKind? asked, out StartSetting? to)
if (wire.Asked is null)
{
- return wire.To is null && !wire.Stop;
+ return wire.To is null && !wire.Stop && !wire.Deps;
}
if (!Named(wire.Asked, out ActionKind kind))
@@ -205,10 +220,11 @@ private static bool Plan(Wire wire, out ActionKind? asked, out StartSetting? to)
if (kind != ActionKind.SetStartType)
{
- return wire.To is null && !wire.Stop;
+ return wire.To is null && !wire.Stop
+ && (!wire.Deps || kind is ActionKind.Stop or ActionKind.Restart or ActionKind.ForceStop or ActionKind.ForceRestart);
}
- if (!Named(wire.To, out StartSetting setting) || (wire.Stop && setting != StartSetting.Disabled))
+ if (wire.Deps || !Named(wire.To, out StartSetting setting) || (wire.Stop && setting != StartSetting.Disabled))
{
return false;
}
@@ -251,6 +267,7 @@ private sealed class Wire
public string? Asked { get; init; }
public string? To { get; init; }
public bool Stop { get; init; }
+ public bool Deps { get; init; }
public bool Left { get; init; }
}
}
diff --git a/src/Bws.Gui/ViewModels/PlanSentence.cs b/src/Bws.Gui/ViewModels/PlanSentence.cs
new file mode 100644
index 0000000..0d94a7f
--- /dev/null
+++ b/src/Bws.Gui/ViewModels/PlanSentence.cs
@@ -0,0 +1,175 @@
+using Bws.Core.Planning;
+
+namespace Bws.Gui.ViewModels;
+
+///
+/// What pressing the button under a sentence of the plan would ask for. Nothing is carried out by
+/// either - each builds the same plan again with one word of the question changed.
+///
+internal enum PlanOffer
+{
+ /// No button under this sentence.
+ None,
+
+ ///
+ /// Under "keeps running": the startup setting with a stop riding on it. Spec C4, UX-GUI-006.
+ ///
+ AlsoStop,
+
+ ///
+ /// Under running dependants the plan does not stop: the same ask with them stopped first, as
+ /// --dependents does. The owner's decision of 2026-09-30 on W-4 of the stability report
+ /// and backlog 496.
+ ///
+ InTheWay
+}
+
+///
+/// One sentence of the plan, and under a few of them a button that builds the plan again with one
+/// word changed.
+///
+/// An object rather than the string these lists held - for the warnings since 2026-09-24 and
+/// for the refusals since 2026-09-30, and an offer is why both times. A button whose meaning depends
+/// on which sentence it stands under needs that sentence to be something it can stand under.
+///
+/// ONE TYPE FOR BOTH LISTS, AND ONE TEMPLATE, SINCE 2026-09-30. Until then this was
+/// PlanWarningLine and offered only under "keeps running". The offer to stop dependants in the way
+/// stands under a warning on a stop and under a refusal on a forced stop, which would have made "a
+/// sentence with a button under it" the third such template on this sheet - GUI rule 2 says the
+/// third time is a component. So the line carries what differs - its offer, the button's name for
+/// instruments and its tooltip - and PlanSentenceTemplate draws every one of them. The failures
+/// under a run keep their own type, PlanFailure, because what they offer opens a different sheet.
+///
+/// ONE OFFER OF EACH KIND PER SHEET, UNDER THE LAST SENTENCE IT ANSWERS, however many entries
+/// that covers. The ask is one - the whole selection, asked again - so taking the offer is one press,
+/// and a button under every sentence would be twenty buttons doing the same thing.
+///
+/// No offer on a record. Once the plan has run the sheet says what happened, and `docs/11`
+/// 3.12 says a sheet asks one question - so the lines come back without the offer, the way the
+/// failure list offers nothing on a plan still waiting to be run.
+///
+public sealed class PlanSentence
+{
+ private PlanSentence(string text, PlanOffer offer, string label)
+ {
+ Text = text;
+ Offer = offer;
+ Label = label;
+ }
+
+ /// The sentence.
+ public string Text { get; }
+
+ /// What the button under it would ask for - for most.
+ internal PlanOffer Offer { get; }
+
+ /// What the offer says on it. Empty when there is none, which the template reads.
+ public string Label { get; }
+
+ /// Whether anything is offered under this sentence at all.
+ public bool HasOffer => Label.Length > 0;
+
+ ///
+ /// The button's name in the automation tree. "planAlsoStop" is kept for the first offer because
+ /// tools/gui-probe/screens.ps1 finds it by that name, and it was the only one until 2026-09-30.
+ ///
+ public string OfferId => Offer switch
+ {
+ PlanOffer.AlsoStop => "planAlsoStop",
+ PlanOffer.InTheWay => "planInTheWay",
+ _ => string.Empty
+ };
+
+ /// What resting on the button says - what it would build, and that nothing happens yet.
+ public string OfferHint => Offer switch
+ {
+ PlanOffer.AlsoStop => Texts.Of("gui.plan.offer.alsoStop.hint"),
+ PlanOffer.InTheWay => Texts.Of("gui.plan.offer.inTheWay.hint"),
+ _ => string.Empty
+ };
+
+ ///
+ /// The lines for these warnings, with each offer under the last sentence it answers - or with
+ /// none when the sheet can no longer be asked anything.
+ ///
+ ///
+ /// What the plan was asked for, because the offer to stop dependants in the way is not the same
+ /// question on every plan that warns about them. A startup setting carrying a stop warns too, and
+ /// the command line refuses --dependents on start-type (OptionSurface) - so a plan rebuilt with
+ /// them would render an equivalent command the terminal rejects.
+ ///
+ internal static IReadOnlyList Of(IReadOnlyList warnings, ActionKind kind, bool offering)
+ {
+ ArgumentNullException.ThrowIfNull(warnings);
+
+ var running = warnings.Count(warning => warning.Kind == PlanWarningKind.KeepsRunning);
+ var inTheWay = kind is ActionKind.Stop or ActionKind.Restart
+ ? warnings.Where(warning => warning.Kind == PlanWarningKind.DependentsInTheWay).ToList()
+ : [];
+
+ var lastRunning = offering ? LastOf(warnings, PlanWarningKind.KeepsRunning) : -1;
+ var lastInTheWay = offering && inTheWay.Count > 0 ? LastOf(warnings, PlanWarningKind.DependentsInTheWay) : -1;
+
+ return
+ [
+ .. warnings.Select((warning, index) =>
+ index == lastRunning ? new PlanSentence(PlanWords.Describe(warning), PlanOffer.AlsoStop, AlsoStopLabel(running))
+ : index == lastInTheWay ? new PlanSentence(PlanWords.Describe(warning), PlanOffer.InTheWay, InTheWayLabel(inTheWay.SelectMany(one => one.Related)))
+ : new PlanSentence(PlanWords.Describe(warning), PlanOffer.None, string.Empty))
+ ];
+ }
+
+ ///
+ /// The lines for the refusals, gathered as they always were, with the offer under the one saying
+ /// dependants are in the way of ending a process - the only way forward from such a sheet.
+ ///
+ internal static IReadOnlyList Of(IReadOnlyList problems, bool offering)
+ {
+ ArgumentNullException.ThrowIfNull(problems);
+
+ // ONE SUCH REFUSAL AT MOST - the window refuses to force more than one entry at a time
+ // (MainWindow.Preview), and only a forcing plan refuses for this. Gathering leaves it as its
+ // own sentence, because it names the dependants, so its words are found in the gathered list.
+ var inTheWay = offering
+ ? problems.FirstOrDefault(problem => problem.Kind == PlanProblemKind.DependentsInTheWay)
+ : null;
+ var offered = inTheWay is null ? null : PlanWords.Describe(inTheWay);
+
+ return
+ [
+ .. PlanWords.Describe(problems).Select(text => text == offered
+ ? new PlanSentence(text, PlanOffer.InTheWay, InTheWayLabel(inTheWay!.Related))
+ : new PlanSentence(text, PlanOffer.None, string.Empty))
+ ];
+ }
+
+ private static int LastOf(IReadOnlyList warnings, PlanWarningKind kind)
+ {
+ for (var index = warnings.Count - 1; index >= 0; index--)
+ {
+ if (warnings[index].Kind == kind)
+ {
+ return index;
+ }
+ }
+
+ return -1;
+ }
+
+ private static string AlsoStopLabel(int running) => running == 1
+ ? Texts.Of("gui.plan.offer.alsoStop.one")
+ : Texts.Of("gui.plan.offer.alsoStop.many", running);
+
+ ///
+ /// "Also stop the 2 in the way" - counted by NAME, because an entry depending on two of the picked
+ /// ones is named under both, and stopping it is one step.
+ ///
+ private static string InTheWayLabel(IEnumerable names)
+ {
+ var count = names.Distinct(StringComparer.OrdinalIgnoreCase).Count();
+
+ return count == 1
+ ? Texts.Of("gui.plan.offer.inTheWay.one")
+ : Texts.Of("gui.plan.offer.inTheWay.many", count);
+ }
+}
diff --git a/src/Bws.Gui/ViewModels/PlanWarningLine.cs b/src/Bws.Gui/ViewModels/PlanWarningLine.cs
deleted file mode 100644
index 05bed7a..0000000
--- a/src/Bws.Gui/ViewModels/PlanWarningLine.cs
+++ /dev/null
@@ -1,64 +0,0 @@
-using Bws.Core.Planning;
-
-namespace Bws.Gui.ViewModels;
-
-///
-/// One sentence under "Worth knowing", and under the one that says a disabled entry keeps running,
-/// the offer to stop it in the same plan.
-///
-/// An object rather than the string this list held until 2026-09-24, and the offer is why -
-/// the same step took for the failures on 2026-09-07. Spec C4 asks the
-/// plan to "propose adding the stop as a separate, explicit step to accept", the owner chose to
-/// have it rebuild the plan in place (UX-GUI-006, variant O1), and a button whose meaning depends
-/// on which sentence it stands under needs that sentence to be something it can stand under.
-///
-/// ONE OFFER PER SHEET, UNDER THE LAST SUCH SENTENCE, however many entries it covers. The
-/// ask is one - the whole selection set to disabled - so taking the offer is one press that
-/// rebuilds it with the stop, and the entries already stopped get no stop step. A button under
-/// every sentence would be twenty buttons all doing the same thing to twenty entries.
-///
-/// No offer on a record. Once the plan has run the sheet says what happened, and `docs/11`
-/// 3.12 says a sheet asks one question - so the lines come back without the offer, the way the
-/// failure list offers nothing on a plan still waiting to be run.
-///
-public sealed class PlanWarningLine
-{
- private PlanWarningLine(string text, string label)
- {
- Text = text;
- Label = label;
- }
-
- /// The sentence.
- public string Text { get; }
-
- /// What the offer says on it. Empty when there is none, which the template reads.
- public string Label { get; }
-
- /// Whether anything is offered under this sentence at all.
- public bool HasOffer => Label.Length > 0;
-
- ///
- /// The lines for these warnings, with the offer under the last one saying an entry keeps
- /// running - or under none of them when the sheet can no longer be asked anything.
- ///
- internal static IReadOnlyList Of(IReadOnlyList warnings, bool offering)
- {
- ArgumentNullException.ThrowIfNull(warnings);
-
- var running = warnings.Count(warning => warning.Kind == PlanWarningKind.KeepsRunning);
- var last = offering && running > 0
- ? warnings.Select((warning, index) => (warning, index))
- .Last(one => one.warning.Kind == PlanWarningKind.KeepsRunning).index
- : -1;
-
- return
- [
- .. warnings.Select((warning, index) => new PlanWarningLine(
- PlanWords.Describe(warning),
- index != last ? string.Empty
- : running == 1 ? Texts.Of("gui.plan.offer.alsoStop.one")
- : Texts.Of("gui.plan.offer.alsoStop.many", running)))
- ];
- }
-}
diff --git a/src/Bws.Gui/ViewModels/Planned.Running.cs b/src/Bws.Gui/ViewModels/Planned.Running.cs
index 4c270de..edbab02 100644
--- a/src/Bws.Gui/ViewModels/Planned.Running.cs
+++ b/src/Bws.Gui/ViewModels/Planned.Running.cs
@@ -11,28 +11,21 @@ namespace Bws.Gui.ViewModels;
/// anybody presses anything. What is here is the only part that changes while a machine is being
/// changed, and it is the part a person watches.
///
-/// The two fields live here with the methods that move them, which is the rule
+/// The flag lives here with the methods that move it, which is the rule
/// MainWindow.Carrying.cs states in as many words: a field read from two files is a field with two
/// stories. Everything that raises or lowers is in this file, and that is what
/// makes the list of ways out of it readable in one place - it is exactly three, and until
/// 2026-09-03 an exception nobody predicted was a fourth way IN with no way out.
+///
+/// What happens DURING a run moved to on 2026-09-30 - the
+/// progress line and, new that day, the two ways of asking a run to stop (backlog 497). This type
+/// stood on the ceiling of methods, and the three methods below are where it hands over: each of
+/// them begins or ends the run over there.
///
public sealed partial class Planned
{
private bool _busy;
- private string _progress = string.Empty;
-
- ///
- /// What the step on screen was announced as, without the clock on the end of it.
- ///
- /// Kept apart from because the clock is rebuilt every second and the
- /// sentence is not. Appending to Progress itself would grow the line once a second, which
- /// is the kind of fault that looks like a memory leak and reads like a stutter.
- ///
- private string _step = string.Empty;
-
- /// When the step on screen was announced, or nothing when none is running.
- private DateTimeOffset? _stepBegan;
+ private Underway? _underway;
/// Whether a run is happening right now.
public bool Busy
@@ -60,37 +53,13 @@ private set
public bool CanClose => !Busy;
///
- /// Which step is happening, while it happens.
+ /// The run as it goes: the step on screen and the two ways of asking it to stop.
///
- /// Empty except during a run. A person watching a stop that takes half a minute has
- /// nothing else to tell them the window is alive - and this window has measured half a minute on
- /// a single step, so the line is not decoration.
+ /// Made on first use rather than in an initialiser, because it asks this sheet's clock, and
+ /// is set by an initialiser that runs after any field initialiser would. The
+ /// clock is handed over as a question rather than a value for the same reason.
///
- public string Progress
- {
- get => _progress;
- private set
- {
- if (Set(ref _progress, value))
- {
- Raise(nameof(HasProgress));
- }
- }
- }
-
- ///
- /// Whether there is a step to report at all - what puts the line on the sheet and takes it
- /// off again.
- ///
- /// The line shares its grid cell with the box of seconds, and until 2026-09-16 it stood
- /// there empty, on top, at all times. A TextBlock takes the pointer over the whole of its
- /// rectangle whether or not it has any text, so an empty sentence stretched across the column
- /// was catching every click meant for the box under it - the owner could not type a number of
- /// seconds into a start or restart plan, and nothing in the markup or the model was wrong.
- /// Measured by tools/gui-probe/plan-keys.ps1 and held by WaitingBoxGuards. The line goes when
- /// it has nothing to say, which is the rule the box of seconds already follows (backlog 203).
- ///
- public bool HasProgress => _progress.Length > 0;
+ public Underway Underway => _underway ??= new Underway(() => _clock, () => Raise(nameof(Notice)));
///
/// A run has begun.
@@ -102,9 +71,10 @@ private set
internal void Starting()
{
Busy = true;
- Progress = string.Empty;
- _step = string.Empty;
- _stepBegan = null;
+
+ // THE LIMIT IS READ HERE, AT THE PRESS, for the line that quotes it - the box it comes from
+ // leaves the sheet in the line below, so nothing can change it for the rest of the run.
+ Underway.Begin(_stepsInPlan, Waiting);
// The button goes quiet here, so what it says about itself has to move with it - otherwise
// a person resting on a greyed button mid-run reads the sentence describing what it would
@@ -120,50 +90,14 @@ internal void Starting()
Raise(nameof(Waits));
RaiseTheProblem();
- // AND THE OFFER UNDER A WARNING, THE SAME FAULT A THIRD TIME, found by review of PR #17.
- // Warnings offers only while nothing runs, but it too was told to look again only when the
+ // AND THE OFFERS UNDER A SENTENCE, THE SAME FAULT A THIRD TIME, found by review of PR #17.
+ // Both lists offer only while nothing runs, but they were told to look again only when the
// run ended - so "Also stop it" stayed live for the whole run, and pressing it rebuilt the
- // plan under a run still going. MainWindow.AlsoStop refuses on the same answer.
+ // plan under a run still going. MainWindow.TakeTheOffer refuses on the same answer.
Raise(nameof(Warnings));
+ Raise(nameof(Problems));
}
- ///
- /// A step is about to be attempted, with its place across the whole selection.
- ///
- /// The number is the step's place in the plan, never a count of attempts, and it arrives
- /// that way from the runner for a reason written there: steps get skipped, and a counter of
- /// attempts calls the sixth step the third one while somebody is trying to work out where a run
- /// has got to.
- ///
- internal void Announce(PlanStep step, int number)
- {
- _step = Texts.Of(
- "gui.plan.progress",
- number,
- _stepsInPlan,
- PlanWords.Word(step.Operation),
- step.ServiceName);
-
- // THE CLOCK RESTARTS PER STEP, NOT PER RUN, because the ceiling is per step. A run of six
- // steps may take six minutes without any one of them being near its limit, and a single
- // number counting up towards sixty through all of it would be a number that means nothing.
- _stepBegan = _clock.Now;
-
- Tick();
- }
-
- ///
- /// Rebuilds the line on screen from the step and how long it has been going.
- ///
- /// Called from a timer while a run is under way - the one
- /// starts, because the window owns the timer and this owns nothing but the sentence. Split from
- /// it so that everything decided here can be checked without one.
- ///
- internal void Tick() =>
- Progress = _stepBegan is not { } began
- ? _step
- : PlanWords.StillWaiting(_step, _clock.Now - began, Waiting);
-
///
/// A run has ended, whether it finished or was interrupted.
///
@@ -177,8 +111,7 @@ internal void Finished(BulkRun run)
_run = run;
Busy = false;
- Progress = string.Empty;
- _stepBegan = null;
+ Underway.End();
Raise(nameof(CanCarryOut));
Raise(nameof(Notice));
@@ -224,6 +157,9 @@ internal void Finished(BulkRun run)
/// asked to be as a skipped step. What it must not do is look untouched, which is why the
/// failure reaches the status line through the window's own net rather than being swallowed
/// here.
+ ///
+ /// And the second way of stopping goes with it, since 2026-09-30 - a button offering to
+ /// abandon a run that is no longer there would be a way to abandon nothing.
///
internal void NoLongerRunning()
{
@@ -235,15 +171,15 @@ internal void NoLongerRunning()
}
Busy = false;
- Progress = string.Empty;
- _stepBegan = null;
+ Underway.End();
Raise(nameof(CanCarryOut));
Raise(nameof(CarryOutTip));
Raise(nameof(Notice));
- // The sheet is a question again rather than a record, so the offer Starting took away
- // comes back with the button.
+ // The sheet is a question again rather than a record, so the offers Starting took away
+ // come back with the button.
Raise(nameof(Warnings));
+ Raise(nameof(Problems));
}
}
diff --git a/src/Bws.Gui/ViewModels/Planned.cs b/src/Bws.Gui/ViewModels/Planned.cs
index 5c3a490..8a9a328 100644
--- a/src/Bws.Gui/ViewModels/Planned.cs
+++ b/src/Bws.Gui/ViewModels/Planned.cs
@@ -270,14 +270,14 @@ private static NamedSentence Line(PlanStep step, int number) =>
/// the ask heavy, which stand in the footer over the confirmation box as
/// since 2026-09-16 and are not repeated here.
///
- /// Lines rather than strings since 2026-09-24, because one of them can carry the offer to
- /// stop the entry too - says why, and why never on a record.
+ /// Lines rather than strings since 2026-09-24, because one of them can carry an offer that
+ /// builds the plan again - says which, and why never on a record.
///
/// Built once for each plan and each state of the offer, since 2026-09-28 - G-7 of the
/// external performance report counted three builds on every showing, one for each binding that
/// asks. Kept beside the plan and the offer it was built for, so it cannot outlive either.
///
- public IReadOnlyList Warnings
+ public IReadOnlyList Warnings
{
get
{
@@ -290,7 +290,7 @@ public IReadOnlyList Warnings
if (_warnings is not { } kept || !ReferenceEquals(kept.Plan, plan) || kept.Offered != offering)
{
- kept = (plan, offering, PlanWarningLine.Of([.. plan.Warnings.Where(warning => !Heavy(warning))], offering));
+ kept = (plan, offering, PlanSentence.Of([.. plan.Warnings.Where(warning => !Heavy(warning))], plan.Action.Kind, offering));
_warnings = kept;
}
@@ -300,7 +300,7 @@ public IReadOnlyList Warnings
// "Offered" rather than "Offering" for the element, which DeadCodeGuards reads by name and would
// take for a caller of ActionBar.Offering.
- private (BulkPlan Plan, bool Offered, IReadOnlyList Lines)? _warnings;
+ private (BulkPlan Plan, bool Offered, IReadOnlyList Lines)? _warnings;
///
/// The entries that get no plan at all, and why.
@@ -314,9 +314,13 @@ public IReadOnlyList Warnings
/// the whole of what changed here on 2026-08-25 - the list itself is unchanged, and so is the
/// decision that put it on screen. How the gathering works and what it deliberately does not
/// fold together is beside the code that does it.
- public IReadOnlyList Problems => _plan is not { } plan
+ ///
+ /// Lines rather than strings since 2026-09-30, for the offer under the one refusal a person
+ /// can answer from here - dependants in the way of ending a process (W-4, backlog 496). Built on
+ /// every read, as the strings were: a sheet of refusals has no run to rebuild it once a second.
+ public IReadOnlyList Problems => _plan is not { } plan
? []
- : PlanWords.Describe(plan.Problems);
+ : PlanSentence.Of(plan.Problems, offering: _run is null && !Busy);
///
/// The same thing from a terminal, one line per entry. `E5`.
@@ -351,8 +355,11 @@ public IReadOnlyList Warnings
/// Only while nothing has been done, which is the same rule the rest of this property
/// follows. Once there is a result the sentence is a report, and a report opening with the
/// reason somebody started would put the older of two facts first.
+ /// And during a run it says which of the two asks the run is under, since 2026-09-30 -
+ /// until then pressing Interrupt left no trace on the sheet at all, and a picture of the sheet
+ /// after the press was the same file to the byte as one before it. words it.
public string Notice => !Showing ? string.Empty
- : Busy ? Texts.Of("gui.plan.notice.running")
+ : Busy ? Underway.Notice
: _run is not { } run
? _because.Length == 0
? Texts.Of("gui.plan.notice.notYet")
@@ -375,9 +382,15 @@ public IReadOnlyList Warnings
/// A run of nothing says so, since 2026-09-29 - G-2 of the external stability report. The
/// one refusal the window records as a run carries no runs at all, and 0 arrived of 0 read as
/// "Done. All 0 entries are where you asked."
+ ///
+ /// A run that was stopped says so, since 2026-09-30 - it used to read like any run that
+ /// fell short, and "What did not is below" pointed at a list that never holds a step nobody tried.
+ /// The command line has said "This run was interrupted" for as long as it could be. Which of the
+ /// two asks it met is read off the steps - .
///
private static string Reported(BulkRun run) =>
run.Runs.Count == 0 ? Texts.Of("gui.plan.notice.nothingRun")
+ : Underway.Afterwards(run, Arrived(run)) is { Length: > 0 } stopped ? stopped
: Arrived(run) == run.Runs.Count
? run.Runs.Count == 1
? Texts.Of("gui.plan.notice.done.one")
@@ -446,7 +459,7 @@ internal bool Show(BulkPlan plan, string? shownAs = null, string? because = null
_wayBack = (null, CommandBlock.None);
_run = null;
Busy = false;
- Progress = string.Empty;
+ Underway.End();
Showing = true;
Raise(nameof(CanCarryOut));
@@ -496,7 +509,7 @@ internal bool Hide()
// in the box for the next.
Typed = string.Empty;
Busy = false;
- Progress = string.Empty;
+ Underway.End();
Showing = false;
Raise(nameof(CanCarryOut));
diff --git a/src/Bws.Gui/ViewModels/Underway.cs b/src/Bws.Gui/ViewModels/Underway.cs
new file mode 100644
index 0000000..de31a38
--- /dev/null
+++ b/src/Bws.Gui/ViewModels/Underway.cs
@@ -0,0 +1,314 @@
+using Bws.Core;
+using Bws.Core.Planning;
+
+namespace Bws.Gui.ViewModels;
+
+///
+/// A run as somebody watches it while it goes: which step it is on, and the two ways of asking it
+/// to stop.
+///
+/// ITS OWN TYPE SINCE 2026-09-30, AND THE SHAPE GUARD IS WHAT ASKED.
+/// stood on the ceiling of methods a type may have, 78 of 78, on the day the window learned the
+/// second way of stopping a run (backlog 497), so nothing could be added to it. The seam is a
+/// subject rather than a count: everything left in Planned is true of a plan before, during and
+/// after a run, and everything here exists only while one is going. The progress line came across
+/// with the two asks, which is what took Planned from 78 to 75.
+///
+/// THE TWO LEVELS ARE THE COMMAND LINE'S TWO PRESSES OF Ctrl+C (Execution.Carry in Bws.Cli).
+/// The first stops going forward and still puts back what the run took down. The second stops
+/// watching and puts nothing back - the expensive ask, and the reason it exists at all is an entry
+/// that reports progress forever: since the step limit counts time without progress (stability
+/// report W-1), such an entry is watched forever, and until this type the window had no way out of
+/// that short of the task manager.
+///
+/// THE RULE THAT GUARDS THE SECOND LEVEL LIVES HERE, NOT IN THE WINDOW, and it has two halves.
+/// The second level is offered only after the first was asked, and only once
+/// has passed with the run still going. The window holds the tokens - its test seam hands it a run
+/// with the sheet idle, and the close guard has to reach the first token whatever this says - but it
+/// cancels the second one only when agrees. The button follows the same answer,
+/// so the door and the button cannot disagree.
+///
+public sealed class Underway : Observable
+{
+ ///
+ /// How long the first level has to have been asked, with the run still going, before the second
+ /// is offered.
+ ///
+ /// A judgement rather than a measurement, and said so in `docs/PROJEKT-PRZERWANIE-I-ZALEZNI-20260930.md`
+ /// where the owner chose it. A double click cannot reach the second button - it stands in
+ /// another rectangle and the first one greys - and a second key press cannot either, because
+ /// greying the focused button hands the keyboard to the window rather than to the next control
+ /// (measured in pwsh on 2026-09-30). What the wait adds is that the button giving up on putting
+ /// things back does not appear at all in the ordinary case, where the step in flight ends a moment
+ /// after the press, and does appear where a run really hangs. Three seconds is more than Windows'
+ /// default double-click time of 500 ms (learn.microsoft.com, SetDoubleClickTime) and nothing
+ /// against a step watched for minutes.
+ ///
+ internal static readonly TimeSpan ArmsAfter = TimeSpan.FromSeconds(3);
+
+ private readonly Func _clock;
+ private readonly Action _noticeMoved;
+
+ private bool _running;
+ private bool _interrupted;
+ private bool _abandoned;
+ private bool _armed;
+ private bool _closing;
+ private TimeSpan _interruptedAt;
+ private int _steps;
+ private int _waiting;
+ private string _progress = string.Empty;
+
+ ///
+ /// What the step on screen was announced as, without the clock on the end of it - kept apart from
+ /// because the clock is rebuilt every second and the sentence is not.
+ /// Appending to Progress itself would grow the line once a second.
+ ///
+ private string _step = string.Empty;
+
+ /// When the step on screen was announced, or nothing when none is running.
+ private DateTimeOffset? _stepBegan;
+
+ ///
+ /// Asked every time rather than kept, because is set by an
+ /// initialiser that may run after this is made.
+ ///
+ ///
+ /// Told when the sentence at the top of the sheet has something new to say - that sentence
+ /// belongs to Planned, which says the plan's state before and after a run as well as during one.
+ ///
+ internal Underway(Func clock, Action noticeMoved)
+ {
+ _clock = clock;
+ _noticeMoved = noticeMoved;
+ }
+
+ ///
+ /// Which step is happening, while it happens. Empty except during a run.
+ ///
+ /// A person watching a stop that takes half a minute has nothing else to tell them the window
+ /// is alive - and this window has measured half a minute on a single step.
+ ///
+ public string Progress
+ {
+ get => _progress;
+ private set
+ {
+ if (Set(ref _progress, value))
+ {
+ Raise(nameof(HasProgress));
+ }
+ }
+ }
+
+ ///
+ /// Whether there is a step to report at all - what puts the line on the sheet and takes it off.
+ ///
+ /// The line goes when it has nothing to say. Until 2026-09-16 it stood on top of the box of
+ /// seconds, empty, and caught every click meant for the box - the owner could not type a number
+ /// into it. It stands in the row under the buttons since 2026-09-30, but a line that goes when it
+ /// is empty is the rule this sheet keeps everywhere (backlog 203).
+ ///
+ public bool HasProgress => _progress.Length > 0;
+
+ /// Whether the first level can still be asked - a run is going and nobody has asked it yet.
+ public bool CanInterrupt => _running && !_interrupted;
+
+ ///
+ /// Whether the second button is on the sheet at all - from the moment it is offered until the run
+ /// ends, grey once pressed, so nothing appears or disappears under a hand twice.
+ ///
+ public bool OffersAbandoning => _running && _interrupted && (_armed || _abandoned);
+
+ /// Whether the second level can be asked now. asks the same question.
+ public bool CanAbandon => _running && _interrupted && _armed && !_abandoned;
+
+ ///
+ /// What the first button says about itself. Once it has been pressed it is grey, and a grey
+ /// button saying what it WOULD do describes the state it has just left.
+ ///
+ public string InterruptTip => _interrupted
+ ? Texts.Of("gui.plan.interrupt.asked")
+ : Texts.Of("gui.plan.interrupt.hint");
+
+ ///
+ /// The sentence at the top of the sheet while a run is going - which of the two asks it is under,
+ /// and whether the window is waiting to close.
+ ///
+ internal string Notice
+ {
+ get
+ {
+ var state = _abandoned ? Texts.Of("gui.plan.notice.abandoning")
+ : _interrupted ? Texts.Of("gui.plan.notice.interrupting")
+ : Texts.Of("gui.plan.notice.running");
+
+ return _closing
+ ? Texts.Of("gui.plan.notice.because", state, Texts.Of("gui.plan.notice.closing"))
+ : state;
+ }
+ }
+
+ ///
+ /// A run has begun: nothing asked of it yet, and nothing announced.
+ ///
+ /// How many steps the plan has, for "step 2 of 5".
+ ///
+ /// The limit the run was given, read at the press. The box it comes from is off the sheet for the
+ /// whole run, so it cannot change under the line that quotes it.
+ ///
+ internal void Begin(int steps, int waiting)
+ {
+ _steps = steps;
+ _waiting = waiting;
+ Reset(running: true);
+ }
+
+ ///
+ /// The run is over, whichever way it ended - and every way out of a run comes here: Finished,
+ /// NoLongerRunning, and a sheet shown or put away. A second button left standing on a sheet with
+ /// no run under it would be a way to abandon nothing.
+ ///
+ internal void End() => Reset(running: false);
+
+ ///
+ /// A step is about to be attempted, with its place across the whole selection - the step's place
+ /// in the plan, never a count of attempts, because steps get skipped.
+ ///
+ internal void Announce(PlanStep step, int number)
+ {
+ _step = Texts.Of("gui.plan.progress", number, _steps, PlanWords.Word(step.Operation), step.ServiceName);
+
+ // THE CLOCK RESTARTS PER STEP, NOT PER RUN, because the limit is per step. A run of six steps
+ // may take six minutes without any one of them being near its limit.
+ _stepBegan = _clock().Now;
+
+ Tick();
+ }
+
+ ///
+ /// Rebuilds the line from the step and how long it has been going, and offers the second level
+ /// once it is due. Called once a second by the window's timer while a run is going.
+ ///
+ internal void Tick()
+ {
+ Progress = _stepBegan is not { } began
+ ? _step
+ : PlanWords.StillWaiting(_step, _clock().Now - began, _waiting);
+
+ // ELAPSED RATHER THAN NOW, because Now is the wall clock and jumps when a virtual machine
+ // resumes (IClock.Elapsed, backlog 299). Across such a jump a wait measured on Now would
+ // arm at once, or never.
+ if (_running && _interrupted && !_armed && _clock().Elapsed - _interruptedAt >= ArmsAfter)
+ {
+ _armed = true;
+ RaiseTheAsks();
+ }
+ }
+
+ ///
+ /// The first level was asked - the button, or somebody reaching for the corner of the window.
+ ///
+ /// Only the first ask counts. A second press - a double click, a close after an interrupt -
+ /// changes nothing, and in particular does not start the wait for the second level again.
+ ///
+ /// Whether the window asked, and will close once the run has ended.
+ internal void Interrupt(bool closing)
+ {
+ if (!_running)
+ {
+ return;
+ }
+
+ _closing |= closing;
+
+ if (!_interrupted)
+ {
+ _interrupted = true;
+ _interruptedAt = _clock().Elapsed;
+ }
+
+ RaiseTheAsks();
+ _noticeMoved();
+ }
+
+ ///
+ /// The second level was asked. True when it may be - and only then does the window cancel the
+ /// second token, so a press arriving early, twice, or with no run under it does nothing.
+ ///
+ internal bool Abandon()
+ {
+ if (!CanAbandon)
+ {
+ return false;
+ }
+
+ _abandoned = true;
+ RaiseTheAsks();
+ _noticeMoved();
+
+ return true;
+ }
+
+ ///
+ /// What the report says about how a finished run was stopped, or nothing when it was not.
+ ///
+ /// READ OFF THE STEPS, NEVER OFF THIS TYPE'S OWN STATE. The report is evidence and outlives
+ /// the run, and the run already writes down which of the two asks it met: one flag for both on
+ /// the run (PlanRunner.Run says why), and a step skipped as cancelled for each thing either ask
+ /// held back. A step going forward held back is the first level. A step putting something back
+ /// held back is the second - the first level never holds those.
+ ///
+ /// An interrupt that arrived after the last step held nothing back, and says nothing here -
+ /// the run's own flag would say "interrupted" about a run that did everything it was asked.
+ ///
+ /// How many of the entries ended where they were asked to be.
+ internal static string Afterwards(BulkRun run, int arrived)
+ {
+ ArgumentNullException.ThrowIfNull(run);
+
+ var held = run.Runs
+ .SelectMany(one => one.Results)
+ .Where(result => result.SkippedBecause == SkipReason.Cancelled)
+ .ToList();
+
+ if (held.Count == 0)
+ {
+ return string.Empty;
+ }
+
+ var nothingPutBack = held.Any(result => result.Step.Reason == StepReason.Restore);
+
+ return (nothingPutBack, run.Runs.Count == 1) switch
+ {
+ (true, true) => Texts.Of("gui.plan.notice.leftAsItWas.one"),
+ (true, false) => Texts.Of("gui.plan.notice.leftAsItWas.many", arrived, run.Runs.Count),
+ (false, true) => Texts.Of("gui.plan.notice.interrupted.one"),
+ (false, false) => Texts.Of("gui.plan.notice.interrupted.many", arrived, run.Runs.Count)
+ };
+ }
+
+ private void Reset(bool running)
+ {
+ _running = running;
+ _interrupted = false;
+ _abandoned = false;
+ _armed = false;
+ _closing = false;
+ _interruptedAt = TimeSpan.Zero;
+ _step = string.Empty;
+ _stepBegan = null;
+ Progress = string.Empty;
+
+ RaiseTheAsks();
+ _noticeMoved();
+ }
+
+ private void RaiseTheAsks()
+ {
+ Raise(nameof(CanInterrupt));
+ Raise(nameof(OffersAbandoning));
+ Raise(nameof(CanAbandon));
+ Raise(nameof(InterruptTip));
+ }
+}
diff --git a/tests/Bws.Architecture.Tests/DeadCodeGuards.cs b/tests/Bws.Architecture.Tests/DeadCodeGuards.cs
index 525db01..e6d7715 100644
--- a/tests/Bws.Architecture.Tests/DeadCodeGuards.cs
+++ b/tests/Bws.Architecture.Tests/DeadCodeGuards.cs
@@ -54,14 +54,13 @@ public sealed class DeadCodeGuards
["Bws.Gui.PlanView.FailureLines"] = ScreenSeam,
["Bws.Gui.PlanView.OfferLines"] = ScreenSeam,
["Bws.Gui.PlanView.WarningLines"] = ScreenSeam,
- ["Bws.Gui.PlanView.AlsoStopLines"] = ScreenSeam,
+ ["Bws.Gui.PlanView.SentenceOffers"] = ScreenSeam,
["Bws.Gui.PlanView.WayBackLines"] = ScreenSeam,
["Bws.Gui.PlanView.ProblemsShown"] = ScreenSeam,
["Bws.Gui.PlanView.WayBackShown"] = ScreenSeam,
["Bws.Gui.PlanView.StepsShown"] = ScreenSeam,
["Bws.Gui.PlanView.CommandsShown"] = ScreenSeam,
["Bws.Gui.MainWindow.TakeThisAsARun"] = TimeSeam,
- ["Bws.Gui.ViewModels.Planned.Clock"] = TimeSeam,
["Bws.Core.Querying.QueryParser.SyntaxVersion"] =
"The version of the query syntax, raised from 1 to 2 when a bare word in the window changed meaning. " +
"The query language document records it as a frozen contract with no reader today, which is a debt " +
diff --git a/tests/Bws.Gui.Tests/AbandonGuards.cs b/tests/Bws.Gui.Tests/AbandonGuards.cs
new file mode 100644
index 0000000..17ad2c5
--- /dev/null
+++ b/tests/Bws.Gui.Tests/AbandonGuards.cs
@@ -0,0 +1,181 @@
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Controls.Primitives;
+using Bws.Core.Planning;
+using Bws.Gui.ViewModels;
+using static Bws.Gui.Tests.PlanFixture;
+
+namespace Bws.Gui.Tests;
+
+///
+/// The second way of stopping a run in the window, asked of the BUTTONS - backlog 497, 2026-09-30.
+///
+/// What is at stake is one press nobody meant. The second level stops watching and puts
+/// nothing back, so a double click on Interrupt reaching it would leave a machine half taken down
+/// with nothing given back. UnderwayTests asks the rule. This asks whether the window keeps it: the
+/// run is a gate the test holds, reached through CarriedOutBy with both tokens, so what each press
+/// really asked of the run is read off the tokens rather than off the screen.
+///
+public sealed class AbandonGuards
+{
+ [Fact]
+ public async Task A_double_press_on_Interrupt_never_reaches_the_second_level()
+ {
+ var run = await Running();
+ var (window, panel, asks) = (run.Window, run.Panel, run.Asks);
+ var footer = window.PlanPanel.Footer;
+
+ WpfHost.On(() => Press(footer.Interrupt));
+ WpfHost.On(() => Press(footer.Interrupt));
+ WpfHost.Settled();
+
+ Assert.True(asks.Stopping.IsCancellationRequested);
+ Assert.False(asks.Abandoning.IsCancellationRequested);
+ Assert.False(WpfHost.On(() => footer.Interrupt.IsEnabled));
+ Assert.Equal(Visibility.Collapsed, WpfHost.On(() => footer.AbandonButton.Visibility));
+ Assert.Equal(Texts.Of("gui.plan.notice.interrupting"), WpfHost.On(() => panel.Notice));
+
+ // A press that reaches the second button before it is offered - by an instrument, or a
+ // routed event from anywhere - is refused at the door, not only by the button being away.
+ WpfHost.On(() => Press(footer.AbandonButton));
+
+ Assert.False(asks.Abandoning.IsCancellationRequested);
+
+ await Finish(run);
+ }
+
+ ///
+ /// Offered once the wait is over, to the LEFT of Interrupt and without moving it, and a press then
+ /// reaches the run. The picture of the foot is left in artifacts/gui.
+ ///
+ [Fact]
+ public async Task The_second_level_appears_to_the_left_and_reaches_the_run()
+ {
+ var run = await Running();
+ var (window, panel, asks, clock) = (run.Window, run.Panel, run.Asks, run.Clock);
+ var footer = window.PlanPanel.Footer;
+
+ WpfHost.On(() => Press(footer.Interrupt));
+ var before = Drawn.Of(window.PlanPanel, 1000, 800).Around(footer.Interrupt);
+
+ clock.Advance(Underway.ArmsAfter);
+ WpfHost.On(() => panel.Underway.Tick());
+ WpfHost.Settled();
+
+ var drawn = Drawn.Of(window.PlanPanel, 1000, 800);
+ var abandon = drawn.Around(footer.AbandonButton);
+ var interrupt = drawn.Around(footer.Interrupt);
+
+ drawn.Save("plan-footer-abandonable-1000x800.png");
+
+ Assert.True(WpfHost.On(() => footer.AbandonButton.IsEnabled));
+ Assert.True(abandon.Width > 0, "the second button was never laid out");
+ Assert.True(abandon.X + abandon.Width <= interrupt.X, $"the second button {abandon} is not left of Interrupt {interrupt}");
+ Assert.Equal(before, interrupt);
+
+ WpfHost.On(() => Press(footer.AbandonButton));
+ WpfHost.Settled();
+
+ Assert.True(asks.Abandoning.IsCancellationRequested);
+ Assert.False(WpfHost.On(() => footer.AbandonButton.IsEnabled));
+ Assert.Equal(Texts.Of("gui.plan.notice.abandoning"), WpfHost.On(() => panel.Notice));
+
+ await Finish(run);
+ }
+
+ ///
+ /// Reaching for the corner of the window asks the first level through the same door, and the sheet
+ /// says the window is waiting. A second reach is not the second level.
+ ///
+ [Fact]
+ public async Task Closing_during_a_run_asks_the_first_level_and_says_the_window_waits()
+ {
+ var run = await Running();
+ var (window, panel, asks) = (run.Window, run.Panel, run.Asks);
+ var closed = false;
+
+ WpfHost.On(() =>
+ {
+ window.Closed += (_, _) => closed = true;
+ window.Close();
+ window.Close();
+ });
+ WpfHost.Settled();
+
+ Assert.False(closed);
+ Assert.True(asks.Stopping.IsCancellationRequested);
+ Assert.False(asks.Abandoning.IsCancellationRequested);
+ Assert.False(WpfHost.On(() => window.PlanPanel.Footer.Interrupt.IsEnabled));
+ Assert.Equal(
+ Texts.Of("gui.plan.notice.because", Texts.Of("gui.plan.notice.interrupting"), Texts.Of("gui.plan.notice.closing")),
+ WpfHost.On(() => panel.Notice));
+
+ run.Gate.SetResult(WpfHost.On(() => Ran(panel)));
+ await run.Carrying;
+ WpfHost.Settled();
+
+ Assert.True(closed);
+ }
+
+ private sealed class Asks
+ {
+ internal CancellationToken Stopping { get; set; }
+
+ internal CancellationToken Abandoning { get; set; }
+ }
+
+ ///
+ /// A run held open by the test, and everything a guard needs to read or end it. Properties rather
+ /// than a positional record, whose constructor would be a sixth parameter over the ceiling.
+ ///
+ private sealed class Held
+ {
+ internal required MainWindow Window { get; init; }
+
+ internal required Planned Panel { get; init; }
+
+ internal required Asks Asks { get; init; }
+
+ internal required TaskCompletionSource Gate { get; init; }
+
+ internal required Task Carrying { get; init; }
+
+ internal required SteppedClock Clock { get; init; }
+ }
+
+ private static async Task Running()
+ {
+ var gate = new TaskCompletionSource();
+ var asks = new Asks();
+ var clock = new SteppedClock();
+ var window = await Ready(
+ carriedOutBy: (_, _, stopping, abandoning, _) =>
+ {
+ asks.Stopping = stopping;
+ asks.Abandoning = abandoning;
+
+ return gate.Task;
+ },
+ clock: clock);
+ var panel = WpfHost.On(() => ((MainViewModel)window.DataContext).Planned);
+
+ Assert.True(await WpfHost.On(() => window.Preview(ActionKind.Stop)));
+
+ var carrying = WpfHost.On(() => window.CarryOut());
+ WpfHost.Settled();
+
+ Assert.True(panel.Busy);
+
+ return new Held { Window = window, Panel = panel, Asks = asks, Gate = gate, Carrying = carrying, Clock = clock };
+ }
+
+ private static async Task Finish(Held run)
+ {
+ run.Gate.SetResult(WpfHost.On(() => Ran(run.Panel)));
+ await run.Carrying;
+ WpfHost.Settled();
+ WpfHost.On(run.Window.Close);
+ }
+
+ private static void Press(Button button) => button.RaiseEvent(new RoutedEventArgs(ButtonBase.ClickEvent));
+}
diff --git a/tests/Bws.Gui.Tests/AlsoStopGuards.cs b/tests/Bws.Gui.Tests/AlsoStopGuards.cs
index 651f510..9dc78a8 100644
--- a/tests/Bws.Gui.Tests/AlsoStopGuards.cs
+++ b/tests/Bws.Gui.Tests/AlsoStopGuards.cs
@@ -9,9 +9,9 @@ namespace Bws.Gui.Tests;
/// The plan that sets a running entry to Disabled says it keeps running, and offers to stop it in
/// the same plan - UX-GUI-006 and spec C4, the owner's variant O1 of 2026-09-24.
///
-/// Asserted on what reaches the screen - WarningLines and AlsoStopLines read the sheet's own
+/// Asserted on what reaches the screen - WarningLines and SentenceOffers read the sheet's own
/// items, so a template that binds to nothing comes back empty rather than right from the model's
-/// side of it. The press itself is asked through the window's own door (AlsoStop), the way the
+/// side of it. The press itself is asked through the window's own door (TakeTheOffer), the way the
/// forced stop's offer is in ForcedStopFixture. That the BUTTON reaches that door was measured on a
/// live window by tools/gui-probe/screens.ps1 -AlsoStop, which presses it through automation.
///
@@ -32,7 +32,7 @@ public async Task Disabling_running_entries_says_so_and_offers_one_stop_for_all_
Assert.Contains(Texts.Of("gui.plan.warning.keepsRunning", "W32Time"), warnings);
Assert.Equal(
[Texts.Of("gui.plan.offer.alsoStop.many", 2)],
- WpfHost.On(() => window.PlanPanel.AlsoStopLines.ToList()));
+ WpfHost.On(() => window.PlanPanel.SentenceOffers.ToList()));
WpfHost.On(window.Close);
}
@@ -47,7 +47,7 @@ public async Task Taking_the_offer_rebuilds_the_sheet_with_the_stop_after_the_se
{
var window = await Opened(onlySpooler: true);
- Assert.True(await WpfHost.On(() => window.AlsoStop()));
+ Assert.True(await WpfHost.On(() => window.TakeTheOffer(PlanOffer.AlsoStop)));
WpfHost.Settled();
var model = WpfHost.On(() => (MainViewModel)window.DataContext);
@@ -63,10 +63,10 @@ public async Task Taking_the_offer_rebuilds_the_sheet_with_the_stop_after_the_se
"Print Spooler",
CellFaces.SettingLabel(StartSetting.Disabled)),
WpfHost.On(() => model.Planned.CarryOutLabel));
- Assert.Empty(WpfHost.On(() => window.PlanPanel.AlsoStopLines.ToList()));
+ Assert.Empty(WpfHost.On(() => window.PlanPanel.SentenceOffers.ToList()));
// And a second press has nothing to take: the sheet already stops.
- Assert.False(await WpfHost.On(() => window.AlsoStop()));
+ Assert.False(await WpfHost.On(() => window.TakeTheOffer(PlanOffer.AlsoStop)));
WpfHost.On(window.Close);
}
@@ -81,16 +81,16 @@ public async Task A_sheet_that_has_run_offers_nothing()
var window = await Opened();
var panel = WpfHost.On(() => ((MainViewModel)window.DataContext).Planned);
- Assert.NotEmpty(WpfHost.On(() => window.PlanPanel.AlsoStopLines.ToList()));
+ Assert.NotEmpty(WpfHost.On(() => window.PlanPanel.SentenceOffers.ToList()));
WpfHost.On(() => panel.Finished(Ran(panel)));
WpfHost.Settled();
Assert.NotEmpty(WpfHost.On(() => window.PlanPanel.WarningLines.ToList()));
- Assert.Empty(WpfHost.On(() => window.PlanPanel.AlsoStopLines.ToList()));
+ Assert.Empty(WpfHost.On(() => window.PlanPanel.SentenceOffers.ToList()));
// And no door behind the button either - a record takes no second question.
- Assert.False(await WpfHost.On(() => window.AlsoStop()));
+ Assert.False(await WpfHost.On(() => window.TakeTheOffer(PlanOffer.AlsoStop)));
WpfHost.On(window.Close);
}
@@ -108,19 +108,19 @@ public async Task A_sheet_that_is_running_offers_nothing_and_takes_no_press()
var window = await Opened(onlySpooler: true);
var panel = WpfHost.On(() => ((MainViewModel)window.DataContext).Planned);
- Assert.NotEmpty(WpfHost.On(() => window.PlanPanel.AlsoStopLines.ToList()));
+ Assert.NotEmpty(WpfHost.On(() => window.PlanPanel.SentenceOffers.ToList()));
WpfHost.On(panel.Starting);
WpfHost.Settled();
- Assert.Empty(WpfHost.On(() => window.PlanPanel.AlsoStopLines.ToList()));
- Assert.False(await WpfHost.On(() => window.AlsoStop()));
+ Assert.Empty(WpfHost.On(() => window.PlanPanel.SentenceOffers.ToList()));
+ Assert.False(await WpfHost.On(() => window.TakeTheOffer(PlanOffer.AlsoStop)));
Assert.Single(WpfHost.On(() => panel.Plan!.Plans.Single().Steps.ToList()));
WpfHost.On(panel.NoLongerRunning);
WpfHost.Settled();
- Assert.NotEmpty(WpfHost.On(() => window.PlanPanel.AlsoStopLines.ToList()));
+ Assert.NotEmpty(WpfHost.On(() => window.PlanPanel.SentenceOffers.ToList()));
WpfHost.On(window.Close);
}
@@ -134,7 +134,7 @@ public async Task The_stop_crosses_a_restart_as_administrator_with_the_setting()
{
var window = await Opened(onlySpooler: true);
- await WpfHost.On(() => window.AlsoStop());
+ await WpfHost.On(() => window.TakeTheOffer(PlanOffer.AlsoStop));
WpfHost.Settled();
var handed = WpfHost.On(window.HandOverNow);
diff --git a/tests/Bws.Gui.Tests/CarryingGuards.cs b/tests/Bws.Gui.Tests/CarryingGuards.cs
index fd7be7f..f9e2bde 100644
--- a/tests/Bws.Gui.Tests/CarryingGuards.cs
+++ b/tests/Bws.Gui.Tests/CarryingGuards.cs
@@ -464,7 +464,7 @@ public async Task A_section_with_nothing_in_it_takes_its_heading_off_the_screen(
[Fact]
public async Task A_run_that_throws_leaves_the_panel_able_to_ask_again()
{
- var window = await Ready(carriedOutBy: (_, _, _, _) =>
+ var window = await Ready(carriedOutBy: (_, _, _, _, _) =>
Task.FromException(new InvalidCastException("nobody predicted this")));
var panel = WpfHost.On(() => (Planned)window.PlanPanel.DataContext);
@@ -478,7 +478,7 @@ public async Task A_run_that_throws_leaves_the_panel_able_to_ask_again()
// Nothing is running, and nothing claims to be.
Assert.False(panel.Busy);
- Assert.Equal(string.Empty, panel.Progress);
+ Assert.Equal(string.Empty, panel.Underway.Progress);
// Read off the window rather than off the model, which is why this class exists: a panel
// that agrees with itself while the button stays grey is the fault this was about.
diff --git a/tests/Bws.Gui.Tests/ForcedStopFixture.cs b/tests/Bws.Gui.Tests/ForcedStopFixture.cs
index ce16880..a5fbd95 100644
--- a/tests/Bws.Gui.Tests/ForcedStopFixture.cs
+++ b/tests/Bws.Gui.Tests/ForcedStopFixture.cs
@@ -102,6 +102,7 @@ internal static Task GaveUp(
BulkPlan plan,
TimeSpan ceiling,
CancellationToken stopping,
+ CancellationToken abandoning,
Action announce) =>
Task.FromResult(new BulkRun
{
diff --git a/tests/Bws.Gui.Tests/HandOverTests.cs b/tests/Bws.Gui.Tests/HandOverTests.cs
index 3681818..19b0b5a 100644
--- a/tests/Bws.Gui.Tests/HandOverTests.cs
+++ b/tests/Bws.Gui.Tests/HandOverTests.cs
@@ -108,7 +108,7 @@ public void The_argument_with_nothing_after_it_is_refused()
public void A_value_longer_than_this_program_writes_is_refused_before_it_is_decoded()
{
var names = string.Join(",", Enumerable.Range(0, 200).Select(index => $"\"Service{index:D12}\""));
- var json = $$"""{"V":1,"Scope":"Services","Query":"{{new string('a', 4000)}}","Picked":[{{names}}]}""";
+ var json = $$"""{"V":2,"Scope":"Services","Query":"{{new string('a', 4000)}}","Picked":[{{names}}]}""";
var value = Base64Url.EncodeToString(Encoding.UTF8.GetBytes(json));
Assert.True(value.Length > HandOver.LongestArgument);
@@ -116,24 +116,30 @@ public void A_value_longer_than_this_program_writes_is_refused_before_it_is_deco
}
[Theory]
- [InlineData("""{"V":2,"Scope":"Services","Query":"","Picked":[]}""")]
- [InlineData("""{"V":1,"Scope":"3","Query":"","Picked":[]}""")]
- [InlineData("""{"V":1,"Scope":"Services, Drivers","Query":"","Picked":[]}""")]
- [InlineData("""{"V":1,"Scope":"services","Query":"","Picked":[]}""")]
- [InlineData("""{"V":1,"Scope":"Services","Picked":[]}""")]
- [InlineData("""{"V":1,"Scope":"Services","Query":"","Picked":["Spool\ner"]}""")]
- [InlineData("""{"V":1,"Scope":"Services","Query":"","Picked":[""]}""")]
- [InlineData("""{"V":1,"Scope":"Services","Query":"","Picked":["Spooler"],"Asked":"Delete"}""")]
- [InlineData("""{"V":1,"Scope":"Services","Query":"","Picked":["Spooler"],"Asked":"Stop","To":"Manual"}""")]
- [InlineData("""{"V":1,"Scope":"Services","Query":"","Picked":["Spooler"],"Asked":"SetStartType"}""")]
- [InlineData("""{"V":1,"Scope":"Services","Query":"","Picked":["Spooler"],"Asked":"SetStartType","To":"Boot, System"}""")]
+ [InlineData("""{"V":1,"Scope":"Services","Query":"","Picked":[]}""")]
+ [InlineData("""{"V":2,"Scope":"3","Query":"","Picked":[]}""")]
+ [InlineData("""{"V":2,"Scope":"Services, Drivers","Query":"","Picked":[]}""")]
+ [InlineData("""{"V":2,"Scope":"services","Query":"","Picked":[]}""")]
+ [InlineData("""{"V":2,"Scope":"Services","Picked":[]}""")]
+ [InlineData("""{"V":2,"Scope":"Services","Query":"","Picked":["Spool\ner"]}""")]
+ [InlineData("""{"V":2,"Scope":"Services","Query":"","Picked":[""]}""")]
+ [InlineData("""{"V":2,"Scope":"Services","Query":"","Picked":["Spooler"],"Asked":"Delete"}""")]
+ [InlineData("""{"V":2,"Scope":"Services","Query":"","Picked":["Spooler"],"Asked":"Stop","To":"Manual"}""")]
+ [InlineData("""{"V":2,"Scope":"Services","Query":"","Picked":["Spooler"],"Asked":"SetStartType"}""")]
+ [InlineData("""{"V":2,"Scope":"Services","Query":"","Picked":["Spooler"],"Asked":"SetStartType","To":"Boot, System"}""")]
// A read-side type this window could never have offered, and the two shapes of a stop riding on
// something it never rides on - since 2026-09-24, when the setting became four values and the
// offer to stop arrived.
- [InlineData("""{"V":1,"Scope":"Services","Query":"","Picked":["Spooler"],"Asked":"SetStartType","To":"Boot"}""")]
- [InlineData("""{"V":1,"Scope":"Services","Query":"","Picked":["Spooler"],"Asked":"SetStartType","To":"Manual","Stop":true}""")]
- [InlineData("""{"V":1,"Scope":"Services","Query":"","Picked":["Spooler"],"Asked":"Stop","Stop":true}""")]
- [InlineData("""{"V":1,"Scope":"Services","Query":"","Picked":["Spooler"],"Stop":true}""")]
+ [InlineData("""{"V":2,"Scope":"Services","Query":"","Picked":["Spooler"],"Asked":"SetStartType","To":"Boot"}""")]
+ [InlineData("""{"V":2,"Scope":"Services","Query":"","Picked":["Spooler"],"Asked":"SetStartType","To":"Manual","Stop":true}""")]
+ [InlineData("""{"V":2,"Scope":"Services","Query":"","Picked":["Spooler"],"Asked":"Stop","Stop":true}""")]
+ [InlineData("""{"V":2,"Scope":"Services","Query":"","Picked":["Spooler"],"Stop":true}""")]
+ // The dependants beside a kind that cannot take them, and with no plan at all - since
+ // 2026-09-30, when the offer to stop them arrived (W-4). EquivalentCommand renders the switch
+ // for stop, restart and the two forcing kinds and nothing else.
+ [InlineData("""{"V":2,"Scope":"Services","Query":"","Picked":["Spooler"],"Asked":"Start","Deps":true}""")]
+ [InlineData("""{"V":2,"Scope":"Services","Query":"","Picked":["Spooler"],"Asked":"SetStartType","To":"Disabled","Stop":true,"Deps":true}""")]
+ [InlineData("""{"V":2,"Scope":"Services","Query":"","Picked":["Spooler"],"Deps":true}""")]
[InlineData("""[[[[[[[[[[]]]]]]]]]]""")]
public void A_hand_over_this_program_could_not_have_written_is_refused_whole(string json)
{
@@ -145,7 +151,7 @@ public void A_hand_over_this_program_could_not_have_written_is_refused_whole(str
[Fact]
public void A_query_longer_than_the_box_would_hold_is_refused()
{
- var json = $$"""{"V":1,"Scope":"Services","Query":"{{new string('a', 5000)}}","Picked":[]}""";
+ var json = $$"""{"V":2,"Scope":"Services","Query":"{{new string('a', 5000)}}","Picked":[]}""";
Assert.Equal((null, true), HandOver.Read([HandOver.Argument, Base64Url.EncodeToString(Encoding.UTF8.GetBytes(json))]));
}
diff --git a/tests/Bws.Gui.Tests/InTheWayGuards.cs b/tests/Bws.Gui.Tests/InTheWayGuards.cs
new file mode 100644
index 0000000..4a86a56
--- /dev/null
+++ b/tests/Bws.Gui.Tests/InTheWayGuards.cs
@@ -0,0 +1,161 @@
+using Bws.Core.Planning;
+using Bws.Gui.ViewModels;
+
+namespace Bws.Gui.Tests;
+
+///
+/// The offer under running dependants the plan does not stop - W-4 of the stability report and
+/// backlog 496, the owner's decision of 2026-09-30.
+///
+/// Why it exists. The window never asked for dependants, so a stop of an entry with running
+/// dependants was refused by the manager with 1051 and a forced stop was refused outright, and the
+/// only way on was to close the sheet and pick them by hand. README said "always in the window".
+///
+/// Asked through the window's own door (TakeTheOffer) and read off the sheet's items, the way
+/// AlsoStopGuards asks the first offer.
+///
+public sealed class InTheWayGuards
+{
+ [Fact]
+ public async Task A_stop_offers_to_stop_them_first_and_rebuilds_the_same_sheet()
+ {
+ var (window, panel) = await Picked("Spooler");
+
+ Assert.True(await WpfHost.On(() => window.Preview(ActionKind.Stop)));
+ Assert.Equal([Texts.Of("gui.plan.offer.inTheWay.many", 2)], WpfHost.On(() => window.PlanPanel.SentenceOffers.ToList()));
+
+ Assert.True(await WpfHost.On(() => window.TakeTheOffer(PlanOffer.InTheWay)));
+ WpfHost.Settled();
+
+ var names = panel.Plan!.Steps.Select(step => step.ServiceName).ToList();
+
+ // The dependants are steps now, before the entry they depend on.
+ Assert.True(panel.Plan.Action.IncludeDependents);
+ Assert.Equal(3, names.Count);
+ Assert.Equal("Spooler", names[^1]);
+ Assert.Equal(["Dnscache", "W32Time"], names.Take(2).Order(StringComparer.Ordinal).ToList());
+ Assert.Empty(WpfHost.On(() => window.PlanPanel.SentenceOffers.ToList()));
+ Assert.Contains(panel.Commands, line => line.Contains("--dependents", StringComparison.Ordinal));
+ Assert.True(WpfHost.On(() => window.HandOverNow().Dependents));
+
+ // Asked for once, so offered no more.
+ Assert.False(await WpfHost.On(() => window.TakeTheOffer(PlanOffer.InTheWay)));
+
+ WpfHost.On(window.Close);
+ }
+
+ ///
+ /// The refusal of a forced stop under running dependants carries the offer, and taking it turns a
+ /// sheet with nothing to run into a plan that stops them before the process is ended.
+ ///
+ [Fact]
+ public async Task A_forced_stop_refused_for_them_offers_the_way_forward()
+ {
+ var (window, panel) = await Picked("Spooler");
+
+ Assert.True(await WpfHost.On(() => window.Preview(ActionKind.ForceStop)));
+ Assert.True(WpfHost.On(() => window.PlanPanel.ProblemsShown));
+ Assert.False(panel.Plan!.IsRunnable);
+ Assert.Equal([Texts.Of("gui.plan.offer.inTheWay.many", 2)], WpfHost.On(() => window.PlanPanel.SentenceOffers.ToList()));
+
+ Assert.True(await WpfHost.On(() => window.TakeTheOffer(PlanOffer.InTheWay)));
+ WpfHost.Settled();
+
+ Assert.True(panel.Plan!.IsRunnable);
+ Assert.True(panel.Plan.Action.IncludeDependents);
+ Assert.Equal(ActionKind.ForceStop, panel.Plan.Action.Kind);
+ Assert.False(WpfHost.On(() => window.PlanPanel.ProblemsShown));
+
+ WpfHost.On(window.Close);
+ }
+
+ ///
+ /// A startup setting carrying a stop warns about them too, and offers nothing: the command line
+ /// refuses --dependents on start-type, so the equivalent command would be one it rejects.
+ ///
+ [Fact]
+ public async Task A_startup_setting_that_stops_offers_nothing_for_them()
+ {
+ var (window, _) = await Picked("Spooler");
+
+ Assert.True(await WpfHost.On(() => window.Preview(ActionKind.SetStartType, StartSetting.Disabled, alsoStop: true)));
+ Assert.Contains(
+ WpfHost.On(() => window.PlanPanel.WarningLines.ToList()),
+ line => line.Contains("W32Time", StringComparison.Ordinal));
+ Assert.Empty(WpfHost.On(() => window.PlanPanel.SentenceOffers.ToList()));
+ Assert.False(await WpfHost.On(() => window.TakeTheOffer(PlanOffer.InTheWay)));
+
+ WpfHost.On(window.Close);
+ }
+
+ ///
+ /// THE OFFER ASKS OVER THE PLAN ON THE SHEET, NOT OVER THE PICKED ROWS. A forcing sheet opened
+ /// from a failure is about the entry that failed, which need not be the row that was picked, and
+ /// asked again over the picked rows the offer would build a plan for a different entry than the
+ /// one on the screen. Moving the selection under an open sheet is the cheapest way to part them.
+ ///
+ [Fact]
+ public async Task The_offer_asks_over_the_plan_on_the_sheet_not_over_the_picked_rows()
+ {
+ var (window, panel) = await Picked("Spooler");
+
+ Assert.True(await WpfHost.On(() => window.Preview(ActionKind.Stop)));
+
+ WpfHost.On(() =>
+ {
+ var model = (MainViewModel)window.DataContext;
+
+ window.Entries.SelectedItems.Clear();
+ window.Entries.SelectedItem = model.Rows.First(row => row.ServiceName == "Dnscache");
+ });
+
+ Assert.True(await WpfHost.On(() => window.TakeTheOffer(PlanOffer.InTheWay)));
+
+ Assert.Equal(["Spooler"], panel.Plan!.Action.ServiceNames);
+
+ WpfHost.On(window.Close);
+ }
+
+ /// Restarting as administrator asks the same plan again, dependants included.
+ [Fact]
+ public void Taking_it_crosses_a_restart_as_administrator()
+ {
+ var sent = new HandOver
+ {
+ Scope = EntryScope.Services,
+ Query = string.Empty,
+ Picked = ["Spooler"],
+ Asked = ActionKind.Restart,
+ Dependents = true
+ };
+
+ var (carried, refused) = HandOver.Read([HandOver.Argument, sent.Encode()]);
+
+ Assert.False(refused);
+ Assert.True(carried!.Dependents);
+ }
+
+ private static async Task<(MainWindow Window, Planned Panel)> Picked(string name)
+ {
+ var machine = new LiveMachine(
+ Rows.Entry("Spooler", "Print Spooler"),
+ Rows.Entry("W32Time", "Windows Time"),
+ Rows.Entry("Dnscache", "DNS Client")).DependedOnBy("Spooler", "W32Time", "Dnscache");
+
+ var model = new MainViewModel(machine, new SteppedClock()) { Planned = new Planned { Elevated = true } };
+
+ await model.LoadAsync();
+
+ var window = WpfHost.Window(model);
+
+ WpfHost.On(() =>
+ {
+ window.Entries.ItemsSource = model.Rows;
+ window.Entries.SelectedItem = model.Rows.First(row => row.ServiceName == name);
+ });
+
+ WpfHost.Settled();
+
+ return (window, model.Planned);
+ }
+}
diff --git a/tests/Bws.Gui.Tests/PlanFixture.cs b/tests/Bws.Gui.Tests/PlanFixture.cs
index 0f58bbb..5458dd4 100644
--- a/tests/Bws.Gui.Tests/PlanFixture.cs
+++ b/tests/Bws.Gui.Tests/PlanFixture.cs
@@ -68,9 +68,15 @@ internal static BulkRun Ran(Planned panel, string? refusing = null)
/// uses, and for the reason written there: the reading happens
/// before the model reaches the window, so nothing is read while bindings are live.
///
+ ///
+ /// The sheet's own clock, for a guard about something that happens only after time passes - the
+ /// second way of stopping a run, since 2026-09-30. Without one the sheet keeps the real clock it
+ /// always had, so no older guard reads a different progress line.
+ ///
internal static async Task Ready(
bool elevated = true,
- Func, Task>? carriedOutBy = null)
+ Func, Task>? carriedOutBy = null,
+ SteppedClock? clock = null)
{
var machine = new LiveMachine(
Rows.Entry("Spooler", "Print Spooler"),
@@ -82,7 +88,7 @@ internal static async Task Ready(
// would be true and every assertion below would pass without the code doing anything.
var model = new MainViewModel(machine, new SteppedClock())
{
- Planned = new Planned { Elevated = elevated }
+ Planned = clock is null ? new Planned { Elevated = elevated } : new Planned { Elevated = elevated, Clock = clock }
};
await model.LoadAsync();
diff --git a/tests/Bws.Gui.Tests/PlanViewGuards.cs b/tests/Bws.Gui.Tests/PlanViewGuards.cs
index 4f51948..b44fc6e 100644
--- a/tests/Bws.Gui.Tests/PlanViewGuards.cs
+++ b/tests/Bws.Gui.Tests/PlanViewGuards.cs
@@ -383,7 +383,7 @@ public async Task Every_word_on_the_panel_can_be_read_on_the_surface_it_is_drawn
Assert.True(read.Count(line => !string.IsNullOrWhiteSpace(line)) >= 5, "Too few lines were found on the panel, so nothing was measured.");
Assert.Contains(WpfHost.On(() => model.Planned.Heading.Text), read);
Assert.Contains(WpfHost.On(() => model.Planned.Steps[0].Text), read);
- Assert.Contains(WpfHost.On(() => model.Planned.Problems[0]), read);
+ Assert.Contains(WpfHost.On(() => model.Planned.Problems[0].Text), read);
Assert.True(
thin.Count == 0,
diff --git a/tests/Bws.Gui.Tests/PluralGuards.cs b/tests/Bws.Gui.Tests/PluralGuards.cs
index 00413ff..7a81527 100644
--- a/tests/Bws.Gui.Tests/PluralGuards.cs
+++ b/tests/Bws.Gui.Tests/PluralGuards.cs
@@ -335,7 +335,7 @@ private static List Everything(Planned panel) =>
.. panel.Steps.Select(line => line.Text),
.. panel.Warnings.Select(line => line.Text),
.. panel.Warnings.Select(line => line.Label),
- .. panel.Problems,
+ .. panel.Problems.Select(line => line.Text),
// BOTH HALVES OF A FAILURE SINCE 2026-09-07, because both reach a screen. The line became
// an object when a way out arrived under it, and reading only the sentence would leave the
// word on that button - the one press in this panel that leads to a process ending -
diff --git a/tests/Bws.Gui.Tests/RunHoldsTheSheetGuards.cs b/tests/Bws.Gui.Tests/RunHoldsTheSheetGuards.cs
index ca57aed..bae7302 100644
--- a/tests/Bws.Gui.Tests/RunHoldsTheSheetGuards.cs
+++ b/tests/Bws.Gui.Tests/RunHoldsTheSheetGuards.cs
@@ -24,7 +24,7 @@ public sealed class RunHoldsTheSheetGuards
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 window = await Ready(carriedOutBy: (_, _, _, _, _) => gate.Task);
var model = WpfHost.On(() => (MainViewModel)window.DataContext);
var panel = model.Planned;
@@ -71,7 +71,7 @@ public async Task Nothing_else_takes_the_sheet_or_starts_a_second_run_while_one_
{
var gate = new TaskCompletionSource();
var starts = 0;
- var window = await Ready(carriedOutBy: (_, _, _, _) =>
+ var window = await Ready(carriedOutBy: (_, _, _, _, _) =>
{
starts++;
@@ -134,7 +134,7 @@ 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, _, _, _) =>
+ var window = await Ready(carriedOutBy: (plan, _, _, _, _) =>
{
starts++;
@@ -154,7 +154,7 @@ public async Task A_press_while_the_window_holds_a_run_starts_nothing()
[Fact]
public async Task A_run_that_carried_nothing_out_does_not_say_done()
{
- var window = await Ready(carriedOutBy: (_, _, _, _) =>
+ var window = await Ready(carriedOutBy: (_, _, _, _, _) =>
Task.FromException(new InvalidOperationException("A plan with problems.")));
var panel = WpfHost.On(() => ((MainViewModel)window.DataContext).Planned);
diff --git a/tests/Bws.Gui.Tests/UnderwayTests.cs b/tests/Bws.Gui.Tests/UnderwayTests.cs
new file mode 100644
index 0000000..47258e5
--- /dev/null
+++ b/tests/Bws.Gui.Tests/UnderwayTests.cs
@@ -0,0 +1,237 @@
+using Bws.Core;
+using Bws.Core.Planning;
+using Bws.Gui.ViewModels;
+
+namespace Bws.Gui.Tests;
+
+///
+/// The two ways of asking a run to stop, as the sheet answers them - backlog 497, 2026-09-30.
+///
+/// No window here, and that is the point of Underway being a type. The rule that keeps a press
+/// nobody meant from leaving a machine with nothing put back is a rule about order and time, and a
+/// stepped clock asks it in microseconds. AbandonGuards asks the same questions of the buttons.
+///
+public sealed class UnderwayTests
+{
+ private static (Underway Run, SteppedClock Clock) Begun()
+ {
+ var clock = new SteppedClock();
+ var run = new Underway(() => clock, () => { });
+
+ run.Begin(steps: 2, waiting: 60);
+
+ return (run, clock);
+ }
+
+ [Fact]
+ public void Interrupting_greys_the_first_button_and_says_so()
+ {
+ var (run, _) = Begun();
+
+ Assert.True(run.CanInterrupt);
+ Assert.Equal(Texts.Of("gui.plan.interrupt.hint"), run.InterruptTip);
+ Assert.Equal(Texts.Of("gui.plan.notice.running"), run.Notice);
+
+ run.Interrupt(closing: false);
+
+ Assert.False(run.CanInterrupt);
+ Assert.Equal(Texts.Of("gui.plan.interrupt.asked"), run.InterruptTip);
+ Assert.Equal(Texts.Of("gui.plan.notice.interrupting"), run.Notice);
+ }
+
+ ///
+ /// THE RULE ITSELF: nothing before the first level, nothing before the wait, once after it - and
+ /// the button stays on the sheet, grey, once pressed, so nothing appears or disappears twice.
+ ///
+ [Fact]
+ public void The_second_level_is_offered_only_after_an_interrupt_and_the_wait()
+ {
+ var (run, clock) = Begun();
+
+ clock.Advance(TimeSpan.FromMinutes(5));
+ run.Tick();
+
+ Assert.False(run.OffersAbandoning);
+ Assert.False(run.Abandon());
+
+ run.Interrupt(closing: false);
+ clock.Advance(Underway.ArmsAfter - TimeSpan.FromMilliseconds(1));
+ run.Tick();
+
+ Assert.False(run.OffersAbandoning);
+ Assert.False(run.CanAbandon);
+ Assert.False(run.Abandon());
+
+ clock.Advance(TimeSpan.FromMilliseconds(1));
+ run.Tick();
+
+ Assert.True(run.OffersAbandoning);
+ Assert.True(run.CanAbandon);
+ Assert.True(run.Abandon());
+
+ Assert.True(run.OffersAbandoning);
+ Assert.False(run.CanAbandon);
+ Assert.False(run.Abandon());
+ Assert.Equal(Texts.Of("gui.plan.notice.abandoning"), run.Notice);
+ }
+
+ /// A second press of Interrupt - a double click - does not start the wait again.
+ [Fact]
+ public void A_second_interrupt_keeps_the_first_one_s_time()
+ {
+ var (run, clock) = Begun();
+
+ run.Interrupt(closing: false);
+ clock.Advance(TimeSpan.FromSeconds(2));
+ run.Interrupt(closing: false);
+ clock.Advance(Underway.ArmsAfter - TimeSpan.FromSeconds(2));
+ run.Tick();
+
+ Assert.True(run.CanAbandon);
+ }
+
+ ///
+ /// THE WAIT IS MEASURED ON THE MONOTONIC CLOCK. The wall clock jumps when a virtual machine resumes
+ /// (IClock.Elapsed), and a jump of an hour must not offer the second level a moment after the first.
+ ///
+ [Fact]
+ public void A_jump_of_the_wall_clock_does_not_offer_the_second_level()
+ {
+ var clock = new Jumping();
+ var run = new Underway(() => clock, () => { });
+
+ run.Begin(steps: 1, waiting: 60);
+ run.Interrupt(closing: false);
+ clock.Now += TimeSpan.FromHours(1);
+ run.Tick();
+
+ Assert.False(run.CanAbandon);
+ }
+
+ [Fact]
+ public void Closing_says_the_window_waits_for_the_run()
+ {
+ var (run, _) = Begun();
+
+ run.Interrupt(closing: true);
+
+ Assert.Equal(
+ Texts.Of("gui.plan.notice.because", Texts.Of("gui.plan.notice.interrupting"), Texts.Of("gui.plan.notice.closing")),
+ run.Notice);
+ }
+
+ ///
+ /// Every way out of a run takes both asks with it - a second button left on a sheet with no run
+ /// under it would be a way to abandon nothing, and an interrupt left standing would grey the
+ /// first button of the next run.
+ ///
+ [Fact]
+ public void The_end_of_a_run_takes_both_asks_away()
+ {
+ var (run, clock) = Begun();
+
+ run.Announce(new PlanStep("Spooler", "Spooler", StepOperation.Stop, StepReason.Requested), 1);
+ run.Interrupt(closing: true);
+ clock.Advance(Underway.ArmsAfter);
+ run.Tick();
+ run.End();
+
+ Assert.False(run.CanInterrupt);
+ Assert.False(run.OffersAbandoning);
+ Assert.False(run.HasProgress);
+
+ run.Begin(steps: 1, waiting: 60);
+
+ Assert.True(run.CanInterrupt);
+ Assert.False(run.OffersAbandoning);
+ Assert.Equal(Texts.Of("gui.plan.notice.running"), run.Notice);
+ }
+
+ ///
+ /// The report reads which ask the run met off its STEPS: a step going forward held back is the
+ /// first level, a step putting something back held back is the second. A run whose flag says it
+ /// was interrupted but which held nothing back reports as any other run.
+ ///
+ [Theory]
+ [InlineData(StepReason.Requested, "gui.plan.notice.interrupted.one")]
+ [InlineData(StepReason.Restore, "gui.plan.notice.leftAsItWas.one")]
+ public void A_stopped_run_says_which_ask_it_met(StepReason held, string key)
+ {
+ var run = Run((StepReason.Requested, null), (held, SkipReason.Cancelled));
+
+ Assert.Equal(Texts.Of(key), Underway.Afterwards(run, arrived: 0));
+ }
+
+ /// And the sheet itself says it, where the sentence about a finished run stands.
+ [Fact]
+ public void A_finished_run_that_was_held_back_says_so_on_the_sheet()
+ {
+ var run = Run((StepReason.Requested, SkipReason.Cancelled));
+ var panel = new Planned { Elevated = true };
+
+ Assert.True(panel.Show(run.Plan));
+
+ panel.Starting();
+ panel.Finished(run);
+
+ Assert.Equal(Texts.Of("gui.plan.notice.interrupted.one"), panel.Notice);
+ }
+
+ [Fact]
+ public void A_run_that_held_nothing_back_says_nothing_about_being_stopped()
+ {
+ var run = Run((StepReason.Requested, null), (StepReason.Restore, null));
+
+ Assert.Equal(string.Empty, Underway.Afterwards(run, arrived: 1));
+ }
+
+ private static BulkRun Run(params (StepReason Reason, SkipReason? Skipped)[] steps)
+ {
+ var planSteps = steps.Select(step => new PlanStep(
+ "Spooler", "Spooler", step.Reason == StepReason.Restore ? StepOperation.Start : StepOperation.Stop, step.Reason)).ToList();
+ var plan = new OperationPlan
+ {
+ Action = new ServiceAction(ActionKind.Restart, "Spooler"),
+ Steps = planSteps,
+ Warnings = [],
+ Problems = []
+ };
+
+ return new BulkRun
+ {
+ Plan = new BulkPlan { Action = new BulkAction(ActionKind.Restart, ["Spooler"]), Plans = [plan], Problems = [] },
+ Runs =
+ [
+ new PlanRun
+ {
+ Plan = plan,
+ Results = [.. planSteps.Zip(steps, (step, each) => new StepResult
+ {
+ Step = step,
+ Outcome = each.Skipped is null ? StepOutcome.Succeeded : StepOutcome.Skipped,
+ SkippedBecause = each.Skipped,
+ Status = EntryStatus.Stopped,
+ ProcessId = Reading.Absent(),
+ ErrorCode = 0,
+ Error = null,
+ Milliseconds = 1
+ })],
+ Cancelled = true,
+ Ceiling = TimeSpan.FromMinutes(1)
+ }
+ ]
+ };
+ }
+
+ /// A clock whose wall time can jump while its monotonic count stands still.
+ private sealed class Jumping : IClock
+ {
+ public DateTimeOffset Now { get; set; } = new(2026, 9, 30, 12, 0, 0, TimeSpan.Zero);
+
+ public TimeSpan Elapsed => TimeSpan.Zero;
+
+ public void Wait(TimeSpan duration)
+ {
+ }
+ }
+}
diff --git a/tests/Bws.Gui.Tests/WaitingGuards.cs b/tests/Bws.Gui.Tests/WaitingGuards.cs
index 82bd294..cc4c44e 100644
--- a/tests/Bws.Gui.Tests/WaitingGuards.cs
+++ b/tests/Bws.Gui.Tests/WaitingGuards.cs
@@ -256,13 +256,13 @@ public void The_clock_restarts_with_each_step()
panel.Show(Plan(StepOperation.Stop));
panel.Starting();
- panel.Announce(new PlanStep("Spooler", "Spooler", StepOperation.Stop, StepReason.Requested), 1);
- var first = panel.Progress;
+ panel.Underway.Announce(new PlanStep("Spooler", "Spooler", StepOperation.Stop, StepReason.Requested), 1);
+ var first = panel.Underway.Progress;
- panel.Announce(new PlanStep("Dnscache", "Dnscache", StepOperation.Stop, StepReason.Requested), 2);
+ panel.Underway.Announce(new PlanStep("Dnscache", "Dnscache", StepOperation.Stop, StepReason.Requested), 2);
- Assert.NotEqual(first, panel.Progress);
- Assert.Contains("Dnscache", panel.Progress, StringComparison.Ordinal);
+ Assert.NotEqual(first, panel.Underway.Progress);
+ Assert.Contains("Dnscache", panel.Underway.Progress, StringComparison.Ordinal);
}
///
@@ -280,7 +280,7 @@ public async Task What_was_typed_into_the_box_is_what_the_run_is_given()
{
TimeSpan? given = null;
- var window = await PlanFixture.Ready(carriedOutBy: (plan, ceiling, _, _) =>
+ var window = await PlanFixture.Ready(carriedOutBy: (plan, ceiling, _, _, _) =>
{
given = ceiling;
return Task.FromResult(new BulkRun { Plan = plan, Runs = [] });
@@ -306,7 +306,7 @@ public async Task A_wrong_box_stops_the_press_before_any_run_is_given_a_number()
{
var asked = false;
- var window = await PlanFixture.Ready(carriedOutBy: (plan, _, _, _) =>
+ var window = await PlanFixture.Ready(carriedOutBy: (plan, _, _, _, _) =>
{
asked = true;
return Task.FromResult(new BulkRun { Plan = plan, Runs = [] });
diff --git a/tests/Bws.Gui.Tests/WpfHost.cs b/tests/Bws.Gui.Tests/WpfHost.cs
index 7fda03e..daf28da 100644
--- a/tests/Bws.Gui.Tests/WpfHost.cs
+++ b/tests/Bws.Gui.Tests/WpfHost.cs
@@ -142,7 +142,7 @@ internal static MainWindow Window()
internal static MainWindow Window(
MainViewModel model,
bool seenTheOverview = true,
- Func, Task>? carriedOutBy = null)
+ Func, Task>? carriedOutBy = null)
{
_ = Resources;