Merge branch 'main' into the group's move and its deletion question

Main took the group's EDIT and DELETE off the GROUPS heading while this branch
was adding a MOVE beside them, so the conflict was about the same six pixels
from both directions. Main's answer wins outright, and it is the better one for
the reason its own message gives: a button beside a heading has no card under a
pointer to mean, and had to work its subject out from the selection or from the
trail. Moving a group had that problem worst of all — the thing it takes with it
is everything on the shelf, and "which shelf" is not a question a button there
could answer plainly.

So the MOVE button is gone and the menu entry it was drawn beside is the whole
of it. That entry was already in this branch, above the separator DELETE sits
below, and it needed no change: the card menu selects whatever was right-clicked
before it runs anything, which is exactly the aiming a group move wants.

Three things went with the button. ShowsGroupActions, which main deleted because
hiding buttons was all it did, and which this branch had extended to hide them
for the move panel as well. CanMoveGroupTarget, which existed to answer whether
that button was worth drawing — CanMoveSelectedHost stays, because the phone
really does leave the host's MOVE out rather than offer a refusal, and a menu
whose entries came and went would be a menu whose items move. And the two test
assertions that read them, which were describing the button rather than the
behaviour; what they were guarding is that the two panels never share the
moment, and IsConfirmingGroupDeletion says that directly.

The move panel and the deletion question both keep their place under the
heading, which is where the buttons were and is now simply where that section
puts things. They still exclude each other, by disarming rather than by a
visibility flag: MoveGroup clears a pending deletion and DeleteGroup folds the
move panel away.

Manual checks 3.3 was rewritten by main for the menu and by this branch for the
tick, and now says both; 3.3a is new and walks a two-level shelf across a vault
boundary, which is the half of this feature no headless test can watch land.
This commit is contained in:
2026-08-04 17:11:06 +02:00
38 changed files with 3416 additions and 165 deletions
@@ -166,9 +166,9 @@ public sealed class HostGridTests : IAsyncLifetime
/// <remarks>
/// <para>
/// The host grid's menu acts on nothing when it is not aimed; this one acts on the <em>wrong group</em>.
/// <c>GroupTarget</c> falls back to the group whose contents are on screen when no card is selected — the
/// right answer for the pair of buttons beside the heading, and the wrong one for a menu that opened on a
/// card, which would then offer to delete a group the pointer is nowhere near.
/// <c>GroupTarget</c> falls back to the group whose contents are on screen when no card is selected, and
/// a menu that opened on a card would then offer to delete a group the pointer is nowhere near. It is
/// also the only way to Edit or Delete a group on the desktop, so this is the only place it is aimed.
/// </para>
/// <para>
/// Open is the one entry that takes a parameter, because <c>OpenGroupCommand</c>'s null is a real
@@ -287,10 +287,10 @@ public sealed class HostGridTests : IAsyncLifetime
/// <remarks>
/// <para>
/// The rule one press was split into two gestures for. Selecting a group aims its EDIT and DELETE at it
/// and does nothing else; opening one is what narrows the grid, and the trail is the way back out of it.
/// While a single press meant both, a group could not be named without every host outside it leaving the
/// screen at the same moment.
/// The rule one press was split into two gestures for. Selecting a group marks it and does nothing else;
/// opening one is what narrows the grid, and the trail is the way back out of it. While a single press
/// meant both, a group could not be named without every host outside it leaving the screen at the same
/// moment.
/// </para>
/// <para>
/// Driven through the properties the cards bind rather than through a click, because what is worth
@@ -299,7 +299,7 @@ public sealed class HostGridTests : IAsyncLifetime
/// </para>
/// </remarks>
[Fact]
public async Task SelectingAGroupAimsItsButtonsAtItAndOpeningOneNarrowsTheGrid()
public async Task SelectingAGroupMarksItAndOpeningOneNarrowsTheGrid()
{
await vault.MoveHostToGroupCommand.ExecuteAsync(
new HostGroupMove(Row(vault, "prod-db"), vault.Groups.Single().EntityId));
@@ -312,8 +312,7 @@ public sealed class HostGridTests : IAsyncLifetime
vault.GroupFilter.ShouldBeNull("one press selects a group and does not open it");
vault.VisibleHosts.Select(row => row.Label)
.ShouldBe(["stage-web"], "so the grid is still the outermost level, and prod-db is inside a group");
vault.GroupTarget.ShouldBeSameAs(production, "what EDIT and DELETE act on");
vault.ShowsGroupActions.ShouldBeTrue();
vault.GroupTarget.ShouldBeSameAs(production, "what a group command with no argument acts on");
vault.OpenGroupCommand.Execute(production);
@@ -324,7 +323,7 @@ public sealed class HostGridTests : IAsyncLifetime
vault.SelectedGroup.ShouldBeNull("the card it was on is not one of the cards on screen any more");
vault.GroupTarget.ShouldBeSameAs(
production, "so the buttons fall back to the group whose contents are showing");
production, "so an unaimed command falls back to the group whose contents are showing");
// Back out, which is the trail's first crumb and nothing else: SHOW ALL was a second control for the
// same job and went with the change.
@@ -333,7 +332,6 @@ public sealed class HostGridTests : IAsyncLifetime
vault.VisibleHosts.Select(row => row.Label)
.ShouldBe(["stage-web"], "ALL HOSTS is the outermost level, not every host in the keychain");
vault.GroupTarget.ShouldBeNull("and nothing is aimed at once no group is open or selected");
vault.ShowsGroupActions.ShouldBeFalse("a pair of buttons with no subject is hidden rather than shown");
}
/// <summary>
@@ -376,35 +374,29 @@ public sealed class HostGridTests : IAsyncLifetime
vault.SelectedGroup.ShouldBeNull("and the same in the other direction");
screen.GroupGrid.SelectedItem.ShouldBeNull();
GroupCard(screen).IsSelected.ShouldBeFalse();
vault.ShowsGroupActions.ShouldBeFalse("so the group's own two buttons have nothing to act on");
});
}
/// <remarks>
/// EDIT takes the group as an argument now, so that the phone can open the editor on a heading without
/// selecting a group and losing the host selection to it — see <c>VaultViewModel.EditGroup</c>. The
/// button beside the cards passes nothing and means "the card that is selected", which is the half of
/// that change that would fail silently: a command refusing a null parameter is a button that never
/// fires, and nothing about the markup would say so.
/// A pair of EDIT and DELETE buttons used to sit beside the GROUPS heading, and the card's own menu is
/// the whole of both now — the menu came second and did the same job better, since it acts on the card
/// under the pointer rather than on <c>GroupTarget</c>. Held here because a button coming back is not a
/// compile error and barely a visible one: it would draw itself in place, aimed with no card selected at
/// the group the trail ends with, which is the mistake the two menu tests above exist to catch.
/// </remarks>
[Fact]
public async Task TheGroupsEditButtonStillActsOnTheSelectedCard()
public async Task AGroupsEditAndDeleteAreOnItsCardsMenuAndNowhereElse()
{
await OnTheGridAsync((screen, _) =>
{
vault.SelectedGroup = vault.VisibleGroups.Single();
Dispatcher.UIThread.RunJobs();
var edit = screen.GetVisualDescendants()
screen.GetVisualDescendants()
.OfType<Button>()
.Single(button => ReferenceEquals(button.Command, vault.EditGroupCommand));
edit.IsEffectivelyEnabled.ShouldBeTrue("the command has to accept the button's empty parameter");
edit.Command.ShouldNotBeNull().Execute(edit.CommandParameter);
vault.IsEditingGroup.ShouldBeTrue();
vault.GroupEditorLabel.ShouldBe("production", "the card that was selected");
.Where(button => ReferenceEquals(button.Command, vault.EditGroupCommand)
|| ReferenceEquals(button.Command, vault.DeleteGroupCommand))
.ShouldBeEmpty("a selected group card puts no buttons on the screen");
});
}
@@ -557,11 +557,10 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
}
/// <remarks>
/// The question replaces the group's row of buttons rather than stacking under it the same rule every
/// other row in this application follows — and it is the tallest thing this section draws: a heading, a
/// consequence, a boxed count, and now a tick with a sentence beside it asking whether the machines go
/// too. The tick is the part worth measuring, because it is a wrapping paragraph inside a control whose
/// own height the layout does not obviously account for.
/// The question opens under the GROUPS heading and pushes the cards down, and it is the tallest thing
/// this section draws: a heading, a consequence, a boxed count, and now a tick with a sentence beside it
/// asking whether the machines go too. The tick is the part worth measuring, because it is a wrapping
/// paragraph inside a control whose own height the layout does not obviously account for.
/// </remarks>
[Fact]
public async Task TheHostsScreenFitsWhileAGroupDeletionIsBeingConfirmed()
@@ -580,10 +579,10 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
/// <remarks>
/// <para>
/// The group's move panel, which takes that same row of buttons and is the wordier of the two: a
/// heading, a combo box, a wrapping paragraph naming everything that travels and everything that does
/// not, and two buttons — above a wrap of group cards and the host grid, all of which still have to fit
/// under it.
/// The group's move panel, under the GROUPS heading beside the deletion question and the wordier of the
/// two: a heading, a combo box, a wrapping paragraph naming everything that travels and everything that
/// does not, and two buttons — above a wrap of group cards and the host grid, all of which still have to
/// fit under it.
/// </para>
/// <para>
/// The state is set here rather than through <c>MoveGroupCommand</c>, which would refuse: this fixture's
@@ -604,7 +603,7 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
vault.SelectedMoveGroupVault = vault.MoveGroupVaultChoices[0];
vault.IsMovingGroup = true;
vault.ShowsGroupActions.ShouldBeFalse("the panel takes the row rather than sharing it");
vault.IsConfirmingGroupDeletion.ShouldBeFalse("the two panels share the space and never the moment");
await MeasureHostsAsync(faults => faults.ShouldBeEmpty("with the group move panel up"));
}
@@ -57,6 +57,17 @@ internal sealed class StubTeamServer : IVaultServer, ITeamApi, IVaultGrantApi
/// <inheritdoc />
public IKeyBindingAuthorizer KeyBinding => throw new NotSupportedException();
/// <summary>
/// A push channel that never pushes.
/// </summary>
/// <remarks>
/// Not <c>NotSupportedException</c> like its neighbours: the background synchronisation loop reads
/// this on every wait, so a layout test that opened a screen would throw from a timer thread rather
/// than draw anything. Waiting for ever is the honest stand-in — an offline layout test has no
/// server to be pushed from.
/// </remarks>
public IVaultEventStream Events => IdleVaultEventStream.Instance;
/// <inheritdoc />
public SyncOptions SyncOptions => new();