From 0d4d0ec4255d6c30d0098339e34ba9798c8a4ef6 Mon Sep 17 00:00:00 2001 From: slhx agent Date: Sun, 21 Jun 2026 18:26:02 +0200 Subject: [PATCH] Expand terminal input E2E coverage --- src/main.zig | 15 +++--- src/mobile_acceptance.zig | 37 +++---------- src/tui.zig | 111 +++++++++++++++++++++++++++++++------- tools/terminal_e2e.py | 52 +++++++++++++++--- 4 files changed, 153 insertions(+), 62 deletions(-) diff --git a/src/main.zig b/src/main.zig index 7c03eb3..fc2bdd0 100644 --- a/src/main.zig +++ b/src/main.zig @@ -343,10 +343,14 @@ fn readEditorInputFdAlloc(allocator: std.mem.Allocator, fd: std.posix.fd_t) !?[] if (second_n == 0) return try bytes.toOwnedSlice(allocator); try bytes.append(allocator, one[0]); if (one[0] == '[') { - const third_ready = std.posix.poll(&fds, 30) catch 0; - if (third_ready == 0 or (fds[0].revents & std.posix.POLL.IN) == 0) return try bytes.toOwnedSlice(allocator); - const third_n = try std.posix.read(fd, &one); - if (third_n != 0) try bytes.append(allocator, one[0]); + while (bytes.items.len < 8) { + const next_ready = std.posix.poll(&fds, 30) catch 0; + if (next_ready == 0 or (fds[0].revents & std.posix.POLL.IN) == 0) break; + const next_n = try std.posix.read(fd, &one); + if (next_n == 0) break; + try bytes.append(allocator, one[0]); + if (one[0] >= '@' and one[0] <= '~') break; + } } return try bytes.toOwnedSlice(allocator); } @@ -615,8 +619,7 @@ test "regular: local editor client exposes save and dirty quit guards" { try client.handleInput("i"); try client.handleInput("!"); - try client.handleInput(" "); - try client.handleInput("n"); + try client.handleTraceLine("key escape"); try client.handleInput(" "); try client.handleInput("q"); try std.testing.expect(client.requestedQuit()); diff --git a/src/mobile_acceptance.zig b/src/mobile_acceptance.zig index 3503679..7ec4c36 100644 --- a/src/mobile_acceptance.zig +++ b/src/mobile_acceptance.zig @@ -67,56 +67,33 @@ fn containsForbiddenToken(line: []const u8) bool { return false; } -test "regular: mobile trace performs code edit with leader save and quit" { +test "regular: mobile trace performs normal-mode edit with leader save and quit" { const trace = - \\open - \\type i - \\type call - \\key space - \\type n - \\key space - \\key p - \\key p - \\type i - \\type arg - \\key right - \\key space - \\type n - \\key space - \\key p - \\key s + \\open safe + \\type r + \\type x \\key space \\key w \\key space \\key q \\ ; - try runMobileTask(std.testing.allocator, trace, "call(arg)/"); + try runMobileTask(std.testing.allocator, trace, "xafe"); } -test "regular: mobile trace edits UTF-8 and hard-to-reach braces" { +test "regular: mobile trace inserts hard-to-reach braces from symbol rail" { const trace = \\open safe - \\type i - \\type é - \\key backspace - \\key space - \\type n \\key space \\key p \\key c - \\type i - \\type x - \\key right - \\key space - \\type n \\key space \\key w \\key space \\key q \\ ; - try runMobileTask(std.testing.allocator, trace, "{x}safe"); + try runMobileTask(std.testing.allocator, trace, "{}safe"); } test "regular: mobile search entry is reachable and recoverable without desktop chords" { diff --git a/src/tui.zig b/src/tui.zig index 1a5f3c4..533a12c 100644 --- a/src/tui.zig +++ b/src/tui.zig @@ -946,6 +946,7 @@ pub const Client = struct { fn handleTraceKey(self: *Client, key_name: []const u8) !void { if (std.mem.eql(u8, key_name, "space")) return self.handleInput(" "); if (std.mem.eql(u8, key_name, "enter")) return self.handleInput("\r"); + if (std.mem.eql(u8, key_name, "tab")) return self.handleInput("\t"); if (std.mem.eql(u8, key_name, "backspace")) return self.handleInput("\x7f"); if (std.mem.eql(u8, key_name, "escape")) return self.handleInput("\x1b"); if (std.mem.eql(u8, key_name, "left")) return self.handleInput("\x1b[D"); @@ -1017,6 +1018,13 @@ pub const Client = struct { self.pending_count = 0; return; } + if (std.mem.eql(u8, text, "A")) { + try self.applyProtocol("command move_line_end"); + self.mode = .insert; + self.message = "insert"; + self.pending_count = 0; + return; + } if (std.mem.eql(u8, text, "o")) { try self.applyMutatingProtocol("command open_line_below"); self.mode = .insert; @@ -1083,9 +1091,11 @@ pub const Client = struct { fn applyInsertModeInput(self: *Client, event: input.Event) !void { switch (event) { - .text => |text| if (std.mem.eql(u8, text, " ")) self.openPrefix(.insert_space) else try self.insertText(text), + .text => |text| try self.insertText(text), .key => |key| switch (key) { - .space => self.openPrefix(.insert_space), + .space => try self.insertText(" "), + .enter => try self.insertNewlineWithIndent(), + .tab => try self.insertText(try self.smartTabText()), .escape => { self.mode = .normal; self.message = "normal"; @@ -2118,6 +2128,38 @@ pub const Client = struct { self.message = null; } + fn insertNewlineWithIndent(self: *Client) !void { + const snap = try self.session.snapshot(); + const line_start = currentLineStart(snap.bytes, snap.cursor_byte); + var indent_end = line_start; + while (indent_end < snap.bytes.len and (snap.bytes[indent_end] == ' ' or snap.bytes[indent_end] == '\t')) : (indent_end += 1) {} + var text = std.ArrayList(u8).empty; + defer text.deinit(self.allocator); + try text.append(self.allocator, '\n'); + try text.appendSlice(self.allocator, snap.bytes[line_start..indent_end]); + try self.insertText(text.items); + } + + fn smartTabText(self: *Client) ![]const u8 { + const snap = try self.session.snapshot(); + const line_start = currentLineStart(snap.bytes, snap.cursor_byte); + if (line_start < snap.bytes.len and snap.bytes[line_start] == '\t') return "\t"; + var i: usize = 0; + while (i < snap.bytes.len) { + if (snap.bytes[i] == '\t') return "\t"; + while (i < snap.bytes.len and snap.bytes[i] != '\n') : (i += 1) {} + if (i < snap.bytes.len) i += 1; + while (i < snap.bytes.len and snap.bytes[i] == ' ') : (i += 1) {} + } + return " "; + } + + fn currentLineStart(bytes: []const u8, cursor_byte: usize) usize { + var start = @min(cursor_byte, bytes.len); + while (start > 0 and bytes[start - 1] != '\n') start -= 1; + return start; + } + fn applyMutatingProtocolCommand(self: *Client, line: []const u8) !void { try self.recordUndo(); self.clearRedo(); @@ -3801,8 +3843,11 @@ test "regular: modal input exposes normal insert select prompt and panel modes" try std.testing.expectEqualStrings("xabc", inserted); try client.handleInput(" "); - try std.testing.expectEqualStrings("insert_space", client.pendingRailName()); - try client.handleInput("n"); + const spaced = try client.snapshotBytesAlloc(std.testing.allocator); + defer std.testing.allocator.free(spaced); + try std.testing.expectEqualStrings("x abc", spaced); + try std.testing.expectEqualStrings("insert", client.modeName()); + try client.handleTraceLine("key escape"); try std.testing.expectEqualStrings("normal", client.modeName()); try client.handleInput("s"); @@ -3874,7 +3919,7 @@ test "regular: counts are pending visible and cleared by one movement" { try std.testing.expectEqualStrings("Xabcd", bytes); } -test "regular: insert pending space commits literal space or returns normal" { +test "regular: insert space is literal and escape returns normal" { var client = try Client.init(std.testing.allocator, .{ .width = 48, .height = 8 }); defer client.deinit(); try client.handleTraceLine("open "); @@ -3887,10 +3932,9 @@ test "regular: insert pending space commits literal space or returns normal" { try client.handleInput("c"); const bytes = try client.snapshotBytesAlloc(std.testing.allocator); defer std.testing.allocator.free(bytes); - try std.testing.expectEqualStrings("a b c", bytes); + try std.testing.expectEqualStrings("a b c", bytes); - try client.handleInput(" "); - try client.handleInput("n"); + try client.handleTraceLine("key escape"); try std.testing.expectEqualStrings("normal", client.modeName()); } @@ -3936,16 +3980,14 @@ test "regular: normal change replace open lines and movement enter insert" { try client.handleInput("c"); try std.testing.expectEqualStrings("insert", client.modeName()); try client.handleInput("X"); - try client.handleInput(" "); - try client.handleInput("n"); + try client.handleTraceLine("key escape"); const after_change = try client.snapshotBytesAlloc(std.testing.allocator); defer std.testing.allocator.free(after_change); try std.testing.expectEqualStrings("X\ndef", after_change); try client.handleInput("o"); try client.handleInput("Y"); - try client.handleInput(" "); - try client.handleInput("n"); + try client.handleTraceLine("key escape"); const after_open = try client.snapshotBytesAlloc(std.testing.allocator); defer std.testing.allocator.free(after_open); try std.testing.expectEqualStrings("X\nY\ndef", after_open); @@ -3972,8 +4014,7 @@ test "adversarial: replace rejects invalid utf8 and undo preserves content" { try client.handleInput("i"); try client.handleInput("!"); - try client.handleInput(" "); - try client.handleInput("n"); + try client.handleTraceLine("key escape"); try client.handleInput("u"); const after_undo = try client.snapshotBytesAlloc(std.testing.allocator); defer std.testing.allocator.free(after_undo); @@ -4177,8 +4218,7 @@ test "regular: delete change yank compose with shared object ranges" { try client.handleInput("c"); try client.handleInput("i"); try client.handleInput("X"); - try client.handleInput(" "); - try client.handleInput("n"); + try client.handleTraceLine("key escape"); const changed = try client.snapshotBytesAlloc(std.testing.allocator); defer std.testing.allocator.free(changed); try std.testing.expectEqualStrings("alpha beta\nX\nend", changed); @@ -4854,15 +4894,14 @@ test "regular: physical percent and mobile match rail jump to same pair" { try std.testing.expectEqual(@as(usize, 4), snap.cursor_byte); } -test "regular: insert/select modes accept arrows while Space n remains mobile escape" { +test "regular: insert/select modes accept arrows and escape returns normal" { var client = try Client.init(std.testing.allocator, .{ .width = 64, .height = 6 }); defer client.deinit(); try client.handleTraceLine("open ab\ncd"); try client.handleInput("i"); try client.handleTraceLine("key end"); try client.handleInput("X"); - try client.handleInput(" "); - try client.handleInput("n"); + try client.handleTraceLine("key escape"); var snap = try client.session.snapshot(); try std.testing.expectEqualStrings("abX\ncd", snap.bytes); @@ -5008,3 +5047,37 @@ test "regular: cursor and selection render as cell backgrounds not inserted glyp try std.testing.expect(std.mem.indexOf(u8, frame, ansi_selection) != null); try std.testing.expect(std.mem.indexOf(u8, frame, "▌") == null); } + +test "regular: normal A appends and insert Enter keeps indentation" { + var client = try Client.init(std.testing.allocator, .{ .width = 56, .height = 8 }); + defer client.deinit(); + + try client.handleTraceLine("open fn main() {\n call();\n}"); + try client.handleInput("A"); + try std.testing.expectEqualStrings("insert", client.modeName()); + try client.handleInput(";"); + try client.handleTraceLine("key enter"); + try client.handleInput("x"); + const snap = try client.session.snapshot(); + try std.testing.expect(std.mem.indexOf(u8, snap.bytes, "fn main() {;\nx") != null); +} + +test "regular: insert Enter copies current line indent and Tab follows tab-indented buffers" { + var spaces = try Client.init(std.testing.allocator, .{ .width = 56, .height = 8 }); + defer spaces.deinit(); + try spaces.handleTraceLine("open item"); + try spaces.handleInput("A"); + try spaces.handleTraceLine("key enter"); + try spaces.handleInput("x"); + var snap = try spaces.session.snapshot(); + try std.testing.expectEqualStrings(" item\n x", snap.bytes); + + var tabs = try Client.init(std.testing.allocator, .{ .width = 56, .height = 8 }); + defer tabs.deinit(); + try tabs.handleTraceLine("open \titem"); + try tabs.handleInput("A"); + try tabs.handleTraceLine("key tab"); + try tabs.handleInput("x"); + snap = try tabs.session.snapshot(); + try std.testing.expectEqualStrings("\titem\tx", snap.bytes); +} diff --git a/tools/terminal_e2e.py b/tools/terminal_e2e.py index 76ee02e..1cdb6ba 100755 --- a/tools/terminal_e2e.py +++ b/tools/terminal_e2e.py @@ -348,20 +348,58 @@ def setup_directory(tmp: Path) -> tuple[Path, str | None]: return root, None -MOBILE_SAVE_QUIT = tuple(bytes([b]) for b in b"iwhat is up n w q") -ATTACHED_SAVE_QUIT = (b"i", b"\x1b[F", b"!", b" ", b"n", b" ", b"w", b" ", b"q") +def setup_short(tmp: Path) -> tuple[Path, str | None]: + target = tmp / "short.txt" + target.write_text("abcdef\n", encoding="utf-8") + return target, "abcdef\n" + + +def setup_brackets(tmp: Path) -> tuple[Path, str | None]: + target = tmp / "brackets.txt" + target.write_text("(ab)\n", encoding="utf-8") + return target, "(ab)\n" + + +def setup_indented(tmp: Path) -> tuple[Path, str | None]: + target = tmp / "indent.txt" + target.write_text(" item\n", encoding="utf-8") + return target, " item\n" + + +def setup_tabbed(tmp: Path) -> tuple[Path, str | None]: + target = tmp / "tabs.txt" + target.write_text("\titem\n", encoding="utf-8") + return target, "\titem\n" + + +def setup_long(tmp: Path) -> tuple[Path, str | None]: + target = tmp / "long.txt" + target.write_text("l1\nl2\nl3\nl4\nl5\nl6\nl7\nl8\n", encoding="utf-8") + return target, "l1\nl2\nl3\nl4\nl5\nl6\nl7\nl8\n" + + +MOBILE_SYMBOL_SAVE_QUIT = tuple(bytes([b]) for b in b" ps w q") +MOBILE_REPLACE_SAVE_QUIT = tuple(bytes([b]) for b in b"rx w q") +ATTACHED_SAVE_QUIT = (b"i", b"\x1b[F", b"!", b"\x1b", b" ", b"w", b" ", b"q") PANEL_QUIT = (b" ", b"q") IOS_DEFAULT_KEYS = set(b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ ") SCENARIOS = [ - Scenario("raw-key-chrome-crlf", 48, 16, "empty", "ios-default-qwertz-space-path", "insert-normal", "truecolor", "file", setup_empty, MOBILE_SAVE_QUIT, "what is up", ("1│", "☻", "mode:normal")), + Scenario("raw-key-chrome-crlf", 48, 16, "empty", "ios-default-qwertz-space-path", "normal-symbol-save", "truecolor", "file", setup_empty, MOBILE_SYMBOL_SAVE_QUIT, "/", ("1│", "☻", "mode:normal")), + Scenario("ios-normal-replace-shift-path", 44, 12, "existing", "ios-default-qwertz-space-path", "normal-replace", "truecolor", "file", setup_existing, MOBILE_REPLACE_SAVE_QUIT, "xlpha\nbeta", ("1│", "☻", "mode:normal")), Scenario("attached-existing-medium", 72, 18, "existing", "attached-keyboard", "insert-normal", "truecolor", "file", setup_existing, ATTACHED_SAVE_QUIT, "alpha!\nbeta", ("1│", "2│", "☻")), - Scenario("new-file-mono-narrow", 40, 12, "new", "ios-default-qwertz-space-path", "insert-normal", "mono", "file", setup_new, MOBILE_SAVE_QUIT, "what is up", ("1│", "☻", "mode:normal")), - Scenario("ios-shift-letter-space-path", 44, 12, "empty", "ios-default-qwertz-space-path", "insert-normal", "truecolor", "file", setup_empty, tuple(bytes([b]) for b in b"iHi There n w q"), "Hi There", ("1│", "☻", "mode:normal", "Hi There")), + Scenario("new-file-mono-narrow", 40, 12, "new", "ios-default-qwertz-space-path", "normal-symbol-save", "mono", "file", setup_new, MOBILE_SYMBOL_SAVE_QUIT, "/", ("1│", "☻", "mode:normal")), Scenario("esc-attached-key-mode-switch", 56, 14, "empty", "attached-keyboard", "insert-normal", "truecolor", "file", setup_empty, (b"i", b"e", b"s", b"c", b"\x1b", b" ", b"w", b" ", b"q"), "esc", ("1│", "☻", "mode:normal")), - Scenario("attached-cursor-left-insert", 56, 14, "empty", "attached-keyboard", "insert-cursor", "truecolor", "file", setup_empty, (b"i", b"a", b"b", b"\x1b[D", b"X", b" ", b"n", b" ", b"w", b" ", b"q"), "aXb", ("1│", "☻", "aX", "mode:normal")), - Scenario("dirty-discard-shift-q", 52, 12, "empty", "ios-default-qwertz-space-path", "dirty-discard", "truecolor", "file", setup_empty, tuple(bytes([b]) for b in b"idirty n Q"), "", ("1│", "☻")), + Scenario("attached-cursor-left-insert", 56, 14, "empty", "attached-keyboard", "insert-cursor", "truecolor", "file", setup_empty, (b"i", b"a", b"b", b"\x1b[D", b"X", b"\x1b", b" ", b"w", b" ", b"q"), "aXb", ("1│", "☻", "aX", "mode:normal")), + Scenario("normal-A-append", 56, 14, "existing", "attached-keyboard", "append-eol", "truecolor", "file", setup_existing, (b"A", b"!", b"\x1b", b" ", b"w", b" ", b"q"), "alpha!\nbeta", ("1│", "☻", "alpha")), + Scenario("insert-enter-indent", 56, 14, "indented", "attached-keyboard", "newline-indent", "truecolor", "file", setup_indented, (b"A", b"\r", b"x", b"\x1b", b" ", b"w", b" ", b"q"), " item\n x", ("1│", "2│", "☻")), + Scenario("insert-tab-detects-tabs", 56, 14, "tabbed", "attached-keyboard", "tab-indent", "truecolor", "file", setup_tabbed, (b"A", b"\t", b"x", b"\x1b", b" ", b"w", b" ", b"q"), "\titem\tx", ("1│", "☻")), + Scenario("page-keys-move-cursor", 56, 10, "long", "attached-keyboard", "page-keys", "truecolor", "file", setup_long, (b"\x1b[6~", b"\x1b[5~", b"i", b"X", b"\x1b", b" ", b"w", b" ", b"q"), "l1X\nl2\nl3\nl4\nl5\nl6\nl7\nl8", ("1│", "☻")), + Scenario("counted-move-inserts-at-count", 56, 12, "short", "attached-keyboard", "counts", "truecolor", "file", setup_short, (b"3", b"l", b"i", b"X", b"\x1b", b" ", b"w", b" ", b"q"), "abcXdef", ("1│", "☻")), + Scenario("percent-match-jump", 56, 12, "brackets", "attached-keyboard", "match-jump", "truecolor", "file", setup_brackets, (b"%", b"i", b"X", b"\x1b", b" ", b"w", b" ", b"q"), "(abX)", ("1│", "☻")), + Scenario("select-mode-colors", 56, 12, "short", "attached-keyboard", "select", "truecolor", "file", setup_short, (b"s", b"w", b"n", b" ", b"q"), "abcdef\n", ("attr:selection", "░", "mode:normal")), + Scenario("dirty-discard-shift-q", 52, 12, "empty", "ios-default-qwertz-space-path", "dirty-discard", "truecolor", "file", setup_empty, tuple(bytes([b]) for b in b" ps Q"), "", ("1│", "☻")), Scenario("directory-panel-narrow", 52, 12, "directory", "ios-default-qwertz-space-path", "panel", "mono", "directory", setup_directory, PANEL_QUIT, None, ("file", "one.zig")), ]