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(); } }