Public Access
Flatten the hosts screen into one board of sections
This commit is contained in:
@@ -0,0 +1,331 @@
|
||||
using DodoSSH.Client.Session;
|
||||
using DodoSSH.Client.Session.Tests;
|
||||
using DodoSSH.Client.Shell.ViewModels;
|
||||
using DodoSSH.Client.Ssh;
|
||||
using DodoSSH.Client.Storage;
|
||||
using DodoSSH.Client.Terminal;
|
||||
using DodoSSH.Crypto;
|
||||
using NSubstitute;
|
||||
|
||||
namespace DodoSSH.Client.App.Layout.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="VaultViewModel.HostSections"/>: the desktop board's own flattening, one section per group.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Split out of <see cref="HostGridTests"/> rather than folded into it, because none of what is here needs a
|
||||
/// realized <c>HostsScreen</c> — it is <see cref="VaultViewModel"/> alone, the same surface
|
||||
/// <c>VaultViewModel.RebuildHostSections</c>, <c>FlattenIntoSections</c> and the monogram properties on
|
||||
/// <see cref="HostRowViewModel"/> already carry doc comments for. This is where those comments are held to
|
||||
/// account.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A real <see cref="VaultViewModel"/> over a real unlocked vault, for the reason every other suite in this
|
||||
/// project uses one: compiled bindings resolve against the declared type, and a stand-in would still have to
|
||||
/// be it.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class HostSectionsTests : IAsyncLifetime
|
||||
{
|
||||
private const string Passphrase = "a sufficiently long passphrase";
|
||||
private const string ServerUrl = "https://dodossh.example";
|
||||
|
||||
/// <remarks>Far below the shipped profile: nothing here attacks a wrap.</remarks>
|
||||
private static readonly Argon2Profile CheapProfile =
|
||||
Argon2Profile.FromStoredParameters(memoryKibibytes: 8 * 1024, passes: 1, parallelism: 1);
|
||||
|
||||
private readonly FakeAccountServer server = new();
|
||||
private readonly StubKeyBinding keyBinding = new();
|
||||
private readonly VaultKnownHostStore knownHosts = new();
|
||||
|
||||
private ClientCacheFactory caches = null!;
|
||||
private TerminalWorkspace workspace = null!;
|
||||
private VaultSession session = null!;
|
||||
private VaultViewModel vault = null!;
|
||||
|
||||
private static CancellationToken Token => TestContext.Current.CancellationToken;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
caches = ClientCacheFactory.ForMemory($"host-sections-{Guid.CreateVersion7():N}");
|
||||
await caches.MigrateAsync(Token);
|
||||
|
||||
await new AccountProvisioner(server, keyBinding, caches, TimeProvider.System, CheapProfile)
|
||||
.EnrollAsync(ServerUrl, Passphrase, "laptop", "Personal", Token);
|
||||
|
||||
var outcome = await new SessionOpener(caches, TimeProvider.System).UnlockAsync(Passphrase, Token);
|
||||
outcome.IsUnlocked.ShouldBeTrue(outcome.Message);
|
||||
session = outcome.Session!;
|
||||
|
||||
workspace = new TerminalWorkspace(
|
||||
new InMemoryTerminalAssetProvider(new Dictionary<string, TerminalAsset>(StringComparer.Ordinal)),
|
||||
Substitute.For<ISshConnectionFactory>(),
|
||||
TimeProvider.System);
|
||||
|
||||
await knownHosts.OpenAsync(session, Token);
|
||||
|
||||
vault = new VaultViewModel(session, workspace, knownHosts, static () => null);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
knownHosts.Close();
|
||||
await workspace.DisposeAsync();
|
||||
await vault.DisposeAsync();
|
||||
caches.Dispose();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The invariant <c>HostsScreen.axaml</c> depends on for a groupless keychain to look exactly as it did
|
||||
/// before groups existed: one section, and its <c>Header</c> is null rather than a heading with nothing
|
||||
/// worth saying. See <c>HostSectionViewModel.HasHeader</c> and the XAML's own <c>IsVisible</c> on it.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task AGrouplessKeychainDrawsOneHeaderlessSection()
|
||||
{
|
||||
await AddHostAsync("prod-db");
|
||||
await AddHostAsync("stage-web");
|
||||
|
||||
vault.HostSections.ShouldHaveSingleItem();
|
||||
vault.HostSections[0].HasHeader.ShouldBeFalse();
|
||||
vault.HostSections[0].Hosts.Select(row => row.Label)
|
||||
.ShouldBe(["prod-db", "stage-web"], ignoreOrder: true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Every group is a section, in label order, "No group" first — the flattening the phone's
|
||||
/// <c>SidebarRows</c> has always drawn, adopted by the desktop board in v5.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The three groups are made out of label order on purpose — "zeta" before "alpha" before "mid" — so a
|
||||
/// pass that happened to preserve creation order rather than sorting would still be caught.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task SectionsAreInLabelOrderWithNoGroupFirst()
|
||||
{
|
||||
await AddGroupAsync("zeta");
|
||||
await AddGroupAsync("alpha");
|
||||
await AddGroupAsync("mid");
|
||||
|
||||
await AddHostAsync("loose");
|
||||
await AddHostInGroupAsync("z-host", "zeta");
|
||||
await AddHostInGroupAsync("a-host", "alpha");
|
||||
await AddHostInGroupAsync("m-host", "mid");
|
||||
|
||||
vault.HostSections.Select(section => section.Header?.Label ?? "No group")
|
||||
.ShouldBe(["No group", "alpha", "mid", "zeta"]);
|
||||
|
||||
vault.HostSections.Single(section => string.Equals(section.Header?.Label, "alpha", StringComparison.Ordinal))
|
||||
.Hosts.Select(row => row.Label).ShouldBe(["a-host"]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An empty group still gets a heading; a filter that empties a group's contents does not remove it, but
|
||||
/// the ungrouped heading disappears entirely once nothing is left under it.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task AnEmptyNamedGroupKeepsItsHeadingAndAnEmptyUngroupedHeadingDropsAway()
|
||||
{
|
||||
await AddGroupAsync("empty-shelf");
|
||||
await AddHostInGroupAsync("only-host", "empty-shelf");
|
||||
|
||||
vault.HostSections.Select(section => section.Header?.Label)
|
||||
.ShouldNotContain("No group", "nothing is ungrouped, so that heading is not drawn at all");
|
||||
|
||||
vault.HostSections.Single(section => string.Equals(section.Header?.Label, "empty-shelf", StringComparison.Ordinal))
|
||||
.Hosts.Select(row => row.Label).ShouldBe(["only-host"]);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Collapsing a section keeps its heading — the count on it is still real — and empties its own card
|
||||
/// list; expanding it again restores the cards. Both go through <c>ToggleGroupCommand</c>, the same
|
||||
/// command the heading's chevron button is bound to.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TogglingASectionEmptiesItsCardsWithoutDroppingItsHeading()
|
||||
{
|
||||
await AddGroupAsync("production");
|
||||
await AddHostInGroupAsync("prod-db", "production");
|
||||
await AddHostInGroupAsync("prod-web", "production");
|
||||
|
||||
var header = vault.HostSections.Single(section => string.Equals(section.Header?.Label, "production", StringComparison.Ordinal)).Header!;
|
||||
|
||||
header.IsExpanded.ShouldBeTrue("nothing has folded it yet");
|
||||
|
||||
vault.ToggleGroupCommand.Execute(header);
|
||||
|
||||
var collapsed = vault.HostSections.Single(section => string.Equals(section.Header?.Label, "production", StringComparison.Ordinal));
|
||||
collapsed.Header!.IsExpanded.ShouldBeFalse();
|
||||
collapsed.Header.Count.ShouldBe(2, "the heading still says how many are under it");
|
||||
collapsed.Hosts.ShouldBeEmpty("but the card list under it is folded away");
|
||||
|
||||
vault.ToggleGroupCommand.Execute(collapsed.Header);
|
||||
|
||||
vault.HostSections.Single(section => string.Equals(section.Header?.Label, "production", StringComparison.Ordinal))
|
||||
.Hosts.Select(row => row.Label).ShouldBe(["prod-db", "prod-web"], ignoreOrder: true);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Collapse all folds every section at once and Expand all — the same command, its label swapped by
|
||||
/// <c>CollapseAllLabel</c> — brings every one of them back. See the remark on that property for why it
|
||||
/// reads "collapse" until every section already is one.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task CollapseAllFoldsEverySectionAndExpandAllRestoresThem()
|
||||
{
|
||||
await AddGroupAsync("alpha");
|
||||
await AddGroupAsync("beta");
|
||||
await AddHostInGroupAsync("a-host", "alpha");
|
||||
await AddHostInGroupAsync("b-host", "beta");
|
||||
await AddHostAsync("loose");
|
||||
|
||||
vault.CollapseAllLabel.ShouldBe("Collapse all", "something is still open, so this is the fold action");
|
||||
|
||||
vault.ToggleAllGroupsCommand.Execute(null);
|
||||
|
||||
vault.HostSections.SelectMany(section => section.Hosts).ShouldBeEmpty("every section folded at once");
|
||||
vault.CollapseAllLabel.ShouldBe("Expand all");
|
||||
|
||||
vault.ToggleAllGroupsCommand.Execute(null);
|
||||
|
||||
vault.HostSections.SelectMany(section => section.Hosts).Select(row => row.Label)
|
||||
.ShouldBe(["loose", "a-host", "b-host"], ignoreOrder: true);
|
||||
vault.CollapseAllLabel.ShouldBe("Collapse all");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Group ▾, Tag ▾ and the find box all narrow the same board at once, and a host has to clear every one
|
||||
/// of them that is active — the toolbar's two flyouts are an AND with each other and with the box, even
|
||||
/// though a single flyout's own ticks are an OR among themselves.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GroupFilterTagFilterAndFindComposeAsAnIntersection()
|
||||
{
|
||||
await AddGroupAsync("alpha");
|
||||
await AddGroupAsync("beta");
|
||||
|
||||
await AddHostInGroupWithTagAsync("a-prod", "alpha", "prod");
|
||||
await AddHostInGroupWithTagAsync("a-dev", "alpha", "dev");
|
||||
await AddHostInGroupWithTagAsync("b-prod", "beta", "prod");
|
||||
|
||||
var alphaChoice = vault.GroupFilterChoices.Single(choice => choice.Label == "alpha");
|
||||
vault.ToggleGroupFilterCommand.Execute(alphaChoice);
|
||||
|
||||
vault.HostBoardOrder.Select(row => row.Label)
|
||||
.ShouldBe(["a-prod", "a-dev"], ignoreOrder: true, "narrowed to alpha, both its hosts");
|
||||
|
||||
var prodChoice = vault.TagFilterChoices.Single(choice => choice.Label == "prod");
|
||||
vault.ToggleTagFilterCommand.Execute(prodChoice);
|
||||
|
||||
vault.HostBoardOrder.Select(row => row.Label)
|
||||
.ShouldBe(["a-prod"], "alpha AND prod, not either alone — b-prod fails the group half");
|
||||
|
||||
vault.HostFilter = "a-dev";
|
||||
|
||||
vault.HostBoardOrder.ShouldBeEmpty(
|
||||
"the find box adds a third condition none of alpha's prod-tagged hosts can pass");
|
||||
|
||||
vault.HostFilter = string.Empty;
|
||||
vault.ResetTagFilterCommand.Execute(null);
|
||||
vault.ResetGroupFilterCommand.Execute(null);
|
||||
|
||||
vault.HostBoardOrder.Select(row => row.Label)
|
||||
.ShouldBe(["a-prod", "a-dev", "b-prod"], ignoreOrder: true, "every filter cleared");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The same label always draws the same two letters and the same hue — across a rebuild, which replaces
|
||||
/// every row object outright, and that is exactly the case worth holding: nothing on the row itself
|
||||
/// survives a reload, so the answer has to come from the label alone.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// FNV-1a rather than <see cref="string.GetHashCode()"/> is the reason this can be asserted at all — see
|
||||
/// <c>HostRowViewModel.MonogramHue</c>. A hash randomised per process would still pass a same-instance
|
||||
/// comparison but never a same-label-after-reload one, which is the case that matters: a card must not
|
||||
/// repaint itself a different colour because the application happened to restart.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheSameLabelDrawsTheSameMonogramAcrossAReload()
|
||||
{
|
||||
await AddHostAsync("prod-db-01");
|
||||
|
||||
var before = Host("prod-db-01");
|
||||
var monogram = before.Monogram;
|
||||
var hue = before.MonogramHue;
|
||||
|
||||
monogram.ShouldBe("pd", "the first letters of the first two hyphen-separated words, lowercased");
|
||||
|
||||
await vault.LoadAsync(Token);
|
||||
|
||||
var after = Host("prod-db-01");
|
||||
after.ShouldNotBeSameAs(before, "the reload replaces every row — this is the case worth holding");
|
||||
after.Monogram.ShouldBe(monogram);
|
||||
after.MonogramHue.ShouldBe(hue);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The one-word rule is a different branch of <c>HostRowViewModel.Monogram</c> than the two-word one
|
||||
/// above, and worth its own case: it is the first two characters of the label rather than the first
|
||||
/// letter of two words, which for a one-word host name are not the same computation by coincidence.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task AOneWordLabelDrawsItsFirstTwoCharacters()
|
||||
{
|
||||
await AddHostAsync("Bastion");
|
||||
|
||||
Host("Bastion").Monogram.ShouldBe("ba");
|
||||
}
|
||||
|
||||
// ---- Helpers ----
|
||||
|
||||
private HostRowViewModel Host(string label) =>
|
||||
vault.Hosts.First(row => string.Equals(row.Label, label, StringComparison.Ordinal));
|
||||
|
||||
private async Task AddGroupAsync(string label)
|
||||
{
|
||||
vault.GroupEditorLabel = label;
|
||||
await vault.SaveGroupCommand.ExecuteAsync(Token);
|
||||
await vault.LoadAsync(Token);
|
||||
}
|
||||
|
||||
private async Task AddHostAsync(string label)
|
||||
{
|
||||
vault.NewHostCommand.Execute(null);
|
||||
vault.EditorLabel = label;
|
||||
vault.EditorHostname = $"{label}.internal";
|
||||
vault.EditorUsername = "deploy";
|
||||
|
||||
await vault.SaveHostCommand.ExecuteAsync(Token);
|
||||
await vault.LoadAsync(Token);
|
||||
}
|
||||
|
||||
private async Task AddHostInGroupAsync(string label, string groupLabel)
|
||||
{
|
||||
vault.NewHostCommand.Execute(null);
|
||||
vault.EditorLabel = label;
|
||||
vault.EditorHostname = $"{label}.internal";
|
||||
vault.EditorUsername = "deploy";
|
||||
vault.EditorSelectedGroup = vault.EditorGroupChoices.Single(
|
||||
choice => string.Equals(choice.Label, groupLabel, StringComparison.Ordinal));
|
||||
|
||||
await vault.SaveHostCommand.ExecuteAsync(Token);
|
||||
await vault.LoadAsync(Token);
|
||||
}
|
||||
|
||||
private async Task AddHostInGroupWithTagAsync(string label, string groupLabel, string tag)
|
||||
{
|
||||
await AddHostInGroupAsync(label, groupLabel);
|
||||
|
||||
vault.SelectedHost = Host(label);
|
||||
vault.EditSelectedHostCommand.Execute(null);
|
||||
|
||||
vault.EditorNewTag = tag;
|
||||
await vault.AddEditorTagCommand.ExecuteAsync(Token);
|
||||
|
||||
await vault.SaveHostCommand.ExecuteAsync(Token);
|
||||
await vault.LoadAsync(Token);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user