Merge branch 'claude/main-page-group-hierarchy-3a3210'

This commit is contained in:
2026-08-04 10:10:19 +02:00
6 changed files with 368 additions and 53 deletions
@@ -3584,12 +3584,22 @@ public sealed class ShellFlowTests : IAsyncLifetime
}
/// <remarks>
/// <para>
/// What dragging a host card onto a group card does. It is the same write the editor makes — one field
/// of the host, pushed straight away — reached without opening a form, because filing thirty imported
/// machines through the editor is thirty rounds of open, pick, save.
/// </para>
/// <para>
/// <b>The card goes into the group and off the level it was dragged from</b>, which is the whole of what
/// a drop looks like on a grid that holds one level of the tree — the host is inside the card it was
/// dropped on now, and that is where it is drawn. It used to stay put and gain a chip. The selection
/// goes with it rather than being restored onto something nobody can see: Connect, Edit and Delete all
/// read that property, and none of them should be aimed at a card that has left the screen. See
/// <c>VaultViewModel.Matches</c> and <c>RebuildVisibleHosts</c>.
/// </para>
/// </remarks>
[Fact]
public async Task MovingAHostToAGroup_FilesItAndLeavesItSelected()
public async Task MovingAHostToAGroup_FilesItAndTakesItOffTheLevelItCameFrom()
{
await UnlockedAsync();
var vault = shell.Vault!;
@@ -3603,23 +3613,166 @@ public sealed class ShellFlowTests : IAsyncLifetime
await vault.MoveHostToGroupCommand.ExecuteAsync(new HostGroupMove(host, group));
vault.Hosts.Single().Host.GroupId.ShouldBe(group);
vault.SelectedHost.ShouldNotBeNull().EntityId.ShouldBe(host.EntityId, "the reload replaces every row");
vault.VisibleHosts.ShouldBeEmpty("the grid is the outermost level and the host is inside a group");
vault.SelectedHost.ShouldBeNull("nothing on screen is it any more");
// Under the group's own heading now, which is what the phone's list draws.
// Under the group's own heading now, which is what the phone's list draws — that list is the whole
// tree flattened, so the host is still in it.
vault.SidebarRows.OfType<SidebarGroupHeader>()
.Single(header => header.GroupId == group)
.Count.ShouldBe(1);
// And the name on the card, which is what the desktop's grid draws instead of that heading — the one
// thing on screen that changes where the host was dropped rather than where it came from.
// The name is on the card, which is what the desktop's grid draws instead of that heading, and what
// says which group a searched-up card came out of.
vault.Hosts.Single().GroupLabel.ShouldBe("production");
vault.Hosts.Single().HasGroup.ShouldBeTrue();
// Opening the group is where it went, and the way to it.
vault.OpenGroupCommand.Execute(vault.Groups.Single());
vault.VisibleHosts.ShouldHaveSingleItem().Label.ShouldBe("prod-db");
// And back out again, which is what the host's own editor is for now that the drop has one target.
vault.OpenGroupCommand.Execute(null);
await vault.MoveHostToGroupCommand.ExecuteAsync(new HostGroupMove(vault.Hosts.Single(), null));
vault.Hosts.Single().Host.GroupId.ShouldBeNull();
vault.Hosts.Single().HasGroup.ShouldBeFalse("and the chip goes with it");
// Coming out of a group is the direction that lands the host back on this level, so here the
// selection does survive the move.
vault.VisibleHosts.ShouldHaveSingleItem().Label.ShouldBe("prod-db");
vault.SelectedHost.ShouldNotBeNull().Label.ShouldBe("prod-db");
}
/// <summary>
/// The grid holds one level of the tree, the way a directory pane holds one directory.
/// </summary>
/// <remarks>
/// A host filed under a group is inside that group and is not also on the screen the group's card sits
/// on. While it was both, opening a group could only ever take hosts away — the level above already had
/// all of them — and the cards were headings rather than places. The phone's list is the deliberate
/// exception and is asserted here beside it: it draws the whole tree flat under headings, because it has
/// no cards to open and nowhere to open one into.
/// </remarks>
[Fact]
public async Task AHostFiledUnderAGroup_IsDrawnInsideItAndNotAtTheLevelAbove()
{
await UnlockedAsync();
var vault = shell.Vault!;
await AddHostAsync(vault, "prod-db");
await AddHostAsync(vault, "stage-web");
await AddGroupAsync(vault, "production");
await FileAsync(vault, "prod-db", "production");
vault.VisibleHosts.Select(row => row.Label)
.ShouldBe(["stage-web"], "the outermost level is what nothing has been filed into");
vault.SidebarRows.OfType<HostRowViewModel>().Select(row => row.Label)
.ShouldBe(["prod-db", "stage-web"], "the phone's list is the whole tree flattened");
vault.OpenGroupCommand.Execute(vault.Groups.Single());
vault.VisibleHosts.Select(row => row.Label).ShouldBe(["prod-db"]);
vault.OpenGroupCommand.Execute(null);
vault.VisibleHosts.Select(row => row.Label).ShouldBe(["stage-web"]);
}
/// <remarks>
/// The one thing on the screen that crosses a group boundary, and it has to be one. A search that looked
/// only at the level it was typed on would answer "no host matches that" about a machine this keychain
/// has got — and finding a machine without first remembering where it was filed is most of what the box
/// is for. Typed at the outermost level it reaches everything; typed inside a group it reaches that
/// group and what is under it, which is the same rule read from where you are standing.
/// </remarks>
[Fact]
public async Task TheFindBox_SearchesInsideTheGroupsRatherThanOnlyTheLevelOnScreen()
{
await UnlockedAsync();
var vault = shell.Vault!;
await AddHostAsync(vault, "prod-db");
await AddHostAsync(vault, "stage-web");
await AddGroupAsync(vault, "estate");
await AddGroupAsync(vault, "production");
await SetGroupParentAsync(vault, "production", "estate");
await FileAsync(vault, "prod-db", "production");
vault.VisibleHosts.ShouldNotContain(row => string.Equals(row.Label, "prod-db", StringComparison.Ordinal));
vault.HostFilter = "prod-db";
vault.VisibleHosts.ShouldHaveSingleItem().Label
.ShouldBe("prod-db", "two levels down, and the box reaches it");
// And inside a group it is that group's subtree: estate holds production, which holds the host.
vault.OpenGroupCommand.Execute(
vault.Groups.Single(row => string.Equals(row.Label, "estate", StringComparison.Ordinal)));
vault.VisibleHosts.ShouldHaveSingleItem().Label.ShouldBe("prod-db");
vault.HostFilter = "stage";
vault.VisibleHosts.ShouldBeEmpty("stage-web is outside estate, so this search does not reach it");
vault.NoVisibleHostsMessage.ShouldContain("ALL HOSTS", Case.Sensitive, "and it says how to widen it");
}
/// <remarks>
/// A grid with no cards in it has to say why, and "they are all filed away" is a different sentence from
/// "there are none" and from "nothing matches what you typed". It is the answer the level-at-a-time grid
/// made reachable: before it, a keychain with hosts in it always drew some.
/// </remarks>
[Fact]
public async Task AKeychainWhoseHostsAreAllFiled_SaysSoRatherThanLookingEmpty()
{
await UnlockedAsync();
var vault = shell.Vault!;
await AddHostAsync(vault, "prod-db");
await AddGroupAsync(vault, "production");
await FileAsync(vault, "prod-db", "production");
vault.HasVisibleHosts.ShouldBeFalse();
vault.NoVisibleHostsMessage.ShouldContain("filed under a group");
vault.NoVisibleHostsMessage.ShouldNotContain(
"No hosts yet", Case.Sensitive, "telling somebody with hosts to add their first one answers nothing");
}
/// <remarks>
/// Where a new thing lands, now that the screen is somewhere rather than everywhere. A host created
/// inside a group and filed under none would vanish from the screen it was created on, which is the
/// papercut that comes free with a grid holding one level — so the editor opens on the group the user
/// is standing in, and the picker shows it before anything is saved.
/// </remarks>
[Fact]
public async Task ANewHostOrGroupStartedInsideAGroup_IsMadeInsideIt()
{
await UnlockedAsync();
var vault = shell.Vault!;
await AddGroupAsync(vault, "production");
vault.OpenGroupCommand.Execute(vault.Groups.Single());
vault.NewHostCommand.Execute(null);
vault.EditorSelectedGroup.ShouldNotBeNull().Label.ShouldBe("production");
vault.EditorLabel = "prod-db";
vault.EditorHostname = "db.internal";
await vault.SaveHostCommand.ExecuteAsync(null);
vault.VisibleHosts.ShouldHaveSingleItem().Label
.ShouldBe("prod-db", "so it is on the screen it was made on");
vault.NewGroupCommand.Execute(null);
vault.GroupEditorSelectedParent.ShouldNotBeNull().Label
.ShouldBe("production", "+ NEW GROUP inside a group makes one inside it");
}
/// <remarks>