using System.Text;
using DodoSSH.Client.Session;
using DodoSSH.Client.Shell.Keyboard;
using DodoSSH.Client.Shell.ViewModels;
using DodoSSH.Client.Terminal;
namespace DodoSSH.Client.App.Tests;
///
/// The phone's terminal keys: what each sends, what a latch does to the next one, the cursor gesture's
/// speeds, the history's refusal to keep what was not echoed, and the arrangement surviving a relaunch.
///
public sealed class TerminalKeyboardTests : IDisposable
{
private readonly string directory =
Path.Combine(Path.GetTempPath(), $"dodossh-keys-{Guid.CreateVersion7():N}");
private readonly List sent = [];
/// What the remote has drawn so far, as the history's echo test reads it.
private string screen = "stan@box:~$ ";
public void Dispose()
{
if (Directory.Exists(directory))
{
Directory.Delete(directory, recursive: true);
}
}
private static byte[] Ascii(string text) => Encoding.ASCII.GetBytes(text);
private static string Shown(byte[] bytes) => Encoding.ASCII.GetString(bytes);
// ── notation ─────────────────────────────────────────────────────────────────────────────────────────
[Theory]
[InlineData(@"\e:wq\r", "\x1b:wq\r")]
[InlineData("^C", "\x03")]
[InlineData("^c", "\x03")]
[InlineData("^[", "\x1b")]
[InlineData("^?", "\x7f")]
[InlineData(@"\x1b[A", "\x1b[A")]
[InlineData(@"\^C", "^C")]
[InlineData("^1", "^1")]
[InlineData("git status", "git status")]
[InlineData(@"a\\b", @"a\b")]
public void Notation_IsReadTheWayATerminalUserWritesIt(string notation, string expected)
{
KeySequence.TryParse(notation, out var bytes).ShouldBeTrue();
Shown(bytes).ShouldBe(expected);
}
[Theory]
[InlineData(@"oops\")]
[InlineData(@"\x1")]
[InlineData(@"\xZZ")]
public void MalformedNotation_IsRefusedRatherThanGuessedAt(string notation) =>
KeySequence.TryParse(notation, out _).ShouldBeFalse();
// ── latches ──────────────────────────────────────────────────────────────────────────────────────────
[Theory]
[InlineData("c", true, false, "\x03")]
[InlineData("C", true, false, "\x03")]
[InlineData(" ", true, false, "\0")]
[InlineData("b", false, true, "\u001bb")]
[InlineData("\x1b[D", true, false, "\x1b[1;5D")]
[InlineData("\x1b[C", true, true, "\x1b[1;7C")]
[InlineData("\x1b[A", false, true, "\x1b[1;3A")]
[InlineData("ls", true, false, "ls")]
[InlineData("\x1b[15~", true, false, "\x1b[15~")]
public void ALatchedModifier_EncodesTheNextKeyTheWayXtermDoes(string key, bool control, bool alt, string expected) =>
Shown(TerminalModifiers.Apply(Ascii(key), control, alt)).ShouldBe(expected);
// ── the cursor gesture ───────────────────────────────────────────────────────────────────────────────
[Fact]
public void ADragInsideTheDeadZone_SendsNothing()
{
var gesture = new ArrowGesture();
gesture.Move(10, -8, 0).ShouldBe(0);
gesture.Advance(2_000).ShouldBe(0);
gesture.Direction.ShouldBe(ArrowDirection.None);
}
[Fact]
public void LeavingTheDeadZone_SendsOneKeyAtOnce_AndTheSlowSpeedWaitsBeforeRepeating()
{
var gesture = new ArrowGesture();
gesture.Move(-30, 0, 1_000).ShouldBe(1);
gesture.Direction.ShouldBe(ArrowDirection.Left);
gesture.Speed.ShouldBe(1);
// Held: nothing more until the first-repeat delay is up, then one per interval.
gesture.Advance(1_000 + ArrowGesture.FirstRepeatDelayMs - 1).ShouldBe(0);
gesture.Advance(1_000 + ArrowGesture.FirstRepeatDelayMs).ShouldBe(1);
gesture.Advance(1_000 + ArrowGesture.FirstRepeatDelayMs + ArrowGesture.IntervalMs(1)).ShouldBe(1);
}
[Fact]
public void DraggingFurtherTheSameWay_ShiftsThroughTheThreeSpeeds()
{
var gesture = new ArrowGesture();
gesture.Move(0, -30, 0);
gesture.Speed.ShouldBe(1);
gesture.Direction.ShouldBe(ArrowDirection.Up);
gesture.Move(0, -ArrowGesture.SecondSpeedAt, 10);
gesture.Speed.ShouldBe(2);
gesture.Move(0, -ArrowGesture.ThirdSpeedAt, 20);
gesture.Speed.ShouldBe(3);
// A second at the fastest speed is many keys; the same second at the slowest is a handful.
var fast = 0;
for (var t = 20L; t <= 1_020; t += 10)
{
fast += gesture.Advance(t);
}
var slow = new ArrowGesture();
var slowCount = slow.Move(0, -30, 0);
for (var t = 0L; t <= 1_000; t += 10)
{
slowCount += slow.Advance(t);
}
fast.ShouldBeGreaterThan(slowCount * 4);
}
[Fact]
public void ASmallDriftOffTheAxis_DoesNotTurnLeftIntoUp()
{
var gesture = new ArrowGesture();
gesture.Move(-40, 0, 0);
gesture.Move(-40, -45, 10);
gesture.Direction.ShouldBe(ArrowDirection.Left);
// Clearly vertical now, past the hysteresis.
gesture.Move(-20, -60, 20).ShouldBe(1);
gesture.Direction.ShouldBe(ArrowDirection.Up);
}
[Fact]
public void ComingBackToTheCentre_Stops()
{
var gesture = new ArrowGesture();
gesture.Move(50, 0, 0);
gesture.Move(3, 2, 10).ShouldBe(0);
gesture.Direction.ShouldBe(ArrowDirection.None);
gesture.Advance(5_000).ShouldBe(0);
}
// ── reading back the echo ────────────────────────────────────────────────────────────────────────────
[Theory]
[InlineData("\x1b[32mstan@box\x1b[0m:~$ ls -la", "stan@box:~$ ls -la")]
[InlineData("old output\r\n$ git statsu\b \b\b \bus", "$ git status")]
[InlineData("$ rm -rf build\r$ ls\x1b[K", "$ ls")]
[InlineData("\x1b]0;stan@box: ~\a$ uptime", "$ uptime")]
[InlineData("$ ech\x1b[1D\x1b[1@o", "$ ecoh")]
public void TheCursorsLine_IsReadAsATerminalWouldDrawIt(string output, string expected) =>
EchoedLine.LastLine(Ascii(output)).ShouldBe(expected);
// ── the history ──────────────────────────────────────────────────────────────────────────────────────
private RecentOutput Recent() => new(Ascii(screen), AlternateScreen: false);
/// Types text one key at a time, echoing each key the way an ordinary shell prompt does.
private void TypeEchoed(TypedCommandHistory history, string text)
{
foreach (var c in text)
{
history.Observe(1, "box", Ascii(c.ToString()), Recent);
screen += c;
}
}
[Fact]
public void ACommandTheShellEchoed_IsKept()
{
var history = new TypedCommandHistory();
TypeEchoed(history, "uptime");
history.Observe(1, "box", "\r"u8, Recent);
history.Entries.ShouldHaveSingleItem().ShouldBe(new HistoryEntry("uptime", "box"));
}
[Fact]
public void APasswordTypedWithEchoOff_IsNeverKept()
{
var history = new TypedCommandHistory();
screen = "[sudo] password for stan: ";
// Typed, but nothing comes back: the prompt line is exactly what it was.
foreach (var c in "hunter2")
{
history.Observe(1, "box", Ascii(c.ToString()), Recent);
}
history.Observe(1, "box", "\r"u8, Recent);
history.Entries.ShouldBeEmpty();
}
[Fact]
public void APasswordTypedAfterAnArrowKey_IsStillNeverKept()
{
var history = new TypedCommandHistory();
screen = "Password: ";
history.Observe(1, "box", "\x1b[D"u8, Recent);
foreach (var c in "s3cret")
{
history.Observe(1, "box", Ascii(c.ToString()), Recent);
}
history.Observe(1, "box", "\r"u8, Recent);
history.Entries.ShouldBeEmpty();
}
[Fact]
public void APromptThatEchoesStars_IsNotACommand()
{
var history = new TypedCommandHistory();
screen = "Passphrase: ";
foreach (var c in "s3cret")
{
history.Observe(1, "box", Ascii(c.ToString()), Recent);
screen += '*';
}
history.Observe(1, "box", "\r"u8, Recent);
history.Entries.ShouldBeEmpty();
}
[Fact]
public void ALineFinishedWithTab_IsKeptAsTheShellCompletedIt()
{
var history = new TypedCommandHistory();
TypeEchoed(history, "cd /var/lo");
history.Observe(1, "box", "\t"u8, Recent);
screen += "g/";
history.Observe(1, "box", "\r"u8, Recent);
history.Entries.ShouldHaveSingleItem().Command.ShouldBe("cd /var/log/");
}
[Fact]
public void NothingTypedIntoAFullScreenProgram_IsKept()
{
var history = new TypedCommandHistory();
RecentOutput InVi() => new(Ascii("~\r\n~\r\nhello"), AlternateScreen: true);
foreach (var c in "hello")
{
history.Observe(1, "box", Ascii(c.ToString()), InVi);
}
history.Observe(1, "box", "\r"u8, InVi);
history.Entries.ShouldBeEmpty();
}
[Fact]
public void RunningACommandAgain_MovesItToTheTopRatherThanListingItTwice()
{
var history = new TypedCommandHistory();
foreach (var command in new[] { "ls", "df -h", "ls" })
{
screen = "$ ";
TypeEchoed(history, command);
history.Observe(1, "box", "\r"u8, Recent);
}
history.Entries.Select(e => e.Command).ShouldBe(["ls", "df -h"]);
}
[Fact]
public void CtrlC_AbandonsTheLine()
{
var history = new TypedCommandHistory();
TypeEchoed(history, "rm -rf /tmp/x");
history.Observe(1, "box", "\x03"u8, Recent);
screen = "stan@box:~$ ";
history.Observe(1, "box", "\r"u8, Recent);
history.Entries.ShouldBeEmpty();
}
// ── the view model ───────────────────────────────────────────────────────────────────────────────────
private ClientSettingsStore Settings() => new(new ClientPaths(directory));
private TerminalKeyboardViewModel Keyboard(ClientSettingsStore? store = null) =>
new(
store ?? Settings(),
() => new TypingTarget(1, "box"),
(_, bytes) =>
{
sent.Add(bytes.ToArray());
return ValueTask.CompletedTask;
},
_ => Recent(),
(_, _, _, _) => Task.FromResult(true),
_ => Task.CompletedTask);
private static KeyButtonViewModel Button(TerminalKeyboardViewModel keyboard, string id) =>
keyboard.Groups.SelectMany(g => g.Keys).First(k => k.Key.Id == id);
[Fact]
public void CtrlOnTheBar_AppliesToTheNextLetterFromTheSoftKeyboard_AndThenLetsGo()
{
var keyboard = Keyboard();
Button(keyboard, "ctrl").PressCommand.Execute(null);
keyboard.IsControlLatched.ShouldBeTrue();
Button(keyboard, "ctrl").IsLatched.ShouldBeTrue();
keyboard.Send(Ascii("c"));
keyboard.Send(Ascii("c"));
sent.Select(Shown).ShouldBe(["\x03", "c"]);
keyboard.IsControlLatched.ShouldBeFalse();
Button(keyboard, "ctrl").IsLatched.ShouldBeFalse();
}
[Fact]
public void TheBarAsShipped_IsTwoRows_ModifiersAboveCursorKeys()
{
var keyboard = Keyboard();
keyboard.BarRows.Count.ShouldBe(2);
keyboard.BarRows[0].Keys.Select(k => k.Label).ShouldBe(["esc", "tab", "ctrl", "alt", "|", "-", "/", "~"]);
keyboard.BarRows[1].Keys.Select(k => k.Label).ShouldBe(["←", "↓", "↑", "→", "paste"]);
}
[Fact]
public void ACustomisedBar_SurvivesARelaunch()
{
var store = Settings();
var keyboard = Keyboard(store);
keyboard.OpenCustomizeCommand.Execute(null);
keyboard.BarEditor[1].MoveUpCommand.Execute(null); // tab before esc
keyboard.BarEditor.Single(r => r.Label == "paste").RemoveCommand.Execute(null);
keyboard.NewKeyLabel = "wq";
keyboard.NewKeySequence = @"\e:wq\r";
keyboard.AddCustomKeyCommand.Execute(null);
var relaunched = Keyboard(store);
var labels = relaunched.BarRows.SelectMany(r => r.Keys).Select(k => k.Label).ToList();
labels[0].ShouldBe("tab");
labels[1].ShouldBe("esc");
labels.ShouldNotContain("paste");
labels[^1].ShouldBe("wq");
relaunched.Groups.Last().Title.ShouldBe("YOUR KEYS");
relaunched.BarRows.SelectMany(r => r.Keys).Last().PressCommand.Execute(null);
Shown(sent.Last()).ShouldBe("\x1b:wq\r");
}
[Fact]
public void AHiddenGroup_IsLeftOffTheKeysTab_AndStaysHidden()
{
var store = Settings();
var keyboard = Keyboard(store);
keyboard.GroupEditor.Single(g => g.Title == "TMUX").IsShown = false;
keyboard.Groups.Select(g => g.Title).ShouldNotContain("TMUX");
Keyboard(store).Groups.Select(g => g.Title).ShouldNotContain("TMUX");
}
[Fact]
public void Reset_PutsTheBarBack_ButKeepsKeysSomebodyWrote()
{
var keyboard = Keyboard();
keyboard.NewKeyLabel = "deploy";
keyboard.NewKeySequence = "make deploy";
keyboard.AddCustomKeyCommand.Execute(null);
keyboard.ResetLayoutCommand.Execute(null);
keyboard.BarRows.SelectMany(r => r.Keys).Select(k => k.Key.Id).ShouldBe(TerminalKeyCatalog.DefaultBar);
keyboard.CustomKeyEditor.ShouldHaveSingleItem().Label.ShouldBe("deploy");
}
[Fact]
public void AKeyWithANonsenseSequence_IsRefusedWithAReason()
{
var keyboard = Keyboard();
keyboard.NewKeyLabel = "bad";
keyboard.NewKeySequence = @"\x1";
keyboard.AddCustomKeyCommand.Execute(null);
keyboard.CustomKeyEditor.ShouldBeEmpty();
keyboard.CustomizeStatus.ShouldNotBeNullOrEmpty();
}
[Fact]
public void TheBarHoldsSixteenKeys_AndSaysSoWhenFull()
{
var keyboard = Keyboard();
foreach (var choice in keyboard.GroupEditor.Single(g => g.Title == "FUNCTION").Keys.ToList())
{
// Rebuilt after every toggle, so look the chip up again each time.
keyboard.GroupEditor.Single(g => g.Title == "FUNCTION").Keys
.Single(k => k.Label == choice.Label).ToggleCommand.Execute(null);
}
keyboard.BarRows.Sum(r => r.Keys.Count).ShouldBe(TerminalKeyCatalog.MaximumBarKeys);
keyboard.CanAddToBar.ShouldBeFalse();
keyboard.CustomizeStatus.ShouldContain("16");
}
[Fact]
public void TheQuickAccessKeyboard_ReopensToTheTabLastUsed()
{
var store = Settings();
Keyboard(store).SelectTabCommand.Execute(QuickAccessTab.History);
var relaunched = Keyboard(store);
relaunched.SelectedTab.ShouldBe(QuickAccessTab.History);
relaunched.IsHistoryTab.ShouldBeTrue();
}
[Fact]
public void AnIdThisBuildDoesNotKnow_IsSkippedRatherThanRefused()
{
var store = Settings();
store.Write(new ClientSettings(TerminalKeyboard: new TerminalKeyboardSettings(
Bar: ["esc", "from-the-future", "up"],
GroupOrder: ["vim", "nonsense"])));
var keyboard = Keyboard(store);
keyboard.BarRows.ShouldHaveSingleItem().Keys.Select(k => k.Key.Id).ShouldBe(["esc", "up"]);
keyboard.Groups[0].Title.ShouldBe("VIM");
keyboard.Groups.Count.ShouldBe(TerminalKeyCatalog.Groups.Count);
}
[Fact]
public void TheGesturesArrows_IgnoreALatchedCtrl()
{
var keyboard = Keyboard();
Button(keyboard, "ctrl").PressCommand.Execute(null);
keyboard.SendArrow(ArrowDirection.Up, 2);
Shown(sent.Single()).ShouldBe("\x1b[A\x1b[A");
keyboard.IsControlLatched.ShouldBeTrue();
}
}