740 lines
31 KiB
Zig
740 lines
31 KiB
Zig
const std = @import("std");
|
|
const input = @import("input.zig");
|
|
const leader_mod = @import("leader.zig");
|
|
const protocol = @import("protocol.zig");
|
|
const replay = @import("replay.zig");
|
|
const session_mod = @import("session.zig");
|
|
const symbol_mod = @import("symbol.zig");
|
|
|
|
// First terminal thin client surface, scriptable for E2E-style tests.
|
|
// req: session/001, session/003, ui/001, coding/001, testing/001, testing/002
|
|
|
|
test {
|
|
_ = Client;
|
|
}
|
|
|
|
pub const Error = error{
|
|
ViewportTooSmall,
|
|
InvalidResize,
|
|
UnknownTraceEvent,
|
|
ProtocolRejected,
|
|
ClientQuit,
|
|
NothingSaved,
|
|
};
|
|
|
|
pub const Viewport = struct {
|
|
width: usize,
|
|
height: usize,
|
|
|
|
pub fn validate(self: Viewport) !void {
|
|
if (self.width == 0 or self.height < 2) return Error.ViewportTooSmall;
|
|
}
|
|
};
|
|
|
|
pub fn runTrace(allocator: std.mem.Allocator, viewport: Viewport, trace: []const u8) !TraceResult {
|
|
var client = try Client.init(allocator, viewport);
|
|
defer client.deinit();
|
|
|
|
var lines = std.mem.splitScalar(u8, trace, '\n');
|
|
while (lines.next()) |line| {
|
|
if (line.len == 0) continue;
|
|
try client.handleTraceLine(line);
|
|
}
|
|
|
|
const frame = try client.render(allocator);
|
|
errdefer allocator.free(frame);
|
|
const saved_bytes = if (client.saved_bytes) |bytes| try allocator.dupe(u8, bytes) else null;
|
|
errdefer if (saved_bytes) |bytes| allocator.free(bytes);
|
|
return .{ .frame = frame, .saved_bytes = saved_bytes, .quit = client.quit };
|
|
}
|
|
|
|
pub const TraceResult = struct {
|
|
frame: []u8,
|
|
saved_bytes: ?[]u8,
|
|
quit: bool,
|
|
|
|
pub fn deinit(self: TraceResult, allocator: std.mem.Allocator) void {
|
|
allocator.free(self.frame);
|
|
if (self.saved_bytes) |bytes| allocator.free(bytes);
|
|
}
|
|
};
|
|
|
|
pub const Client = struct {
|
|
allocator: std.mem.Allocator,
|
|
session: session_mod.Session,
|
|
leader: leader_mod.Leader,
|
|
viewport: Viewport,
|
|
saved_bytes: ?[]u8 = null,
|
|
message: ?[]const u8 = null,
|
|
quit: bool = false,
|
|
|
|
pub fn init(allocator: std.mem.Allocator, viewport: Viewport) !Client {
|
|
try viewport.validate();
|
|
return .{
|
|
.allocator = allocator,
|
|
.session = session_mod.Session.init(allocator),
|
|
.leader = leader_mod.Leader.init(allocator),
|
|
.viewport = viewport,
|
|
};
|
|
}
|
|
|
|
pub fn deinit(self: *Client) void {
|
|
if (self.saved_bytes) |bytes| self.allocator.free(bytes);
|
|
self.leader.deinit();
|
|
self.session.deinit();
|
|
self.* = undefined;
|
|
}
|
|
|
|
pub fn handleTraceLine(self: *Client, line: []const u8) !void {
|
|
if (std.mem.eql(u8, line, "quit")) {
|
|
self.quit = true;
|
|
return;
|
|
}
|
|
if (self.quit) return Error.ClientQuit;
|
|
|
|
if (std.mem.startsWith(u8, line, "key ")) return self.handleTraceKey(line[4..]);
|
|
if (std.mem.startsWith(u8, line, "type ")) return self.handleInput(line[5..]);
|
|
if (std.mem.startsWith(u8, line, "panel_open ")) return self.applyProtocolCommand(line);
|
|
if (std.mem.startsWith(u8, line, "list_open ")) return self.applyProtocolCommand(line);
|
|
if (std.mem.startsWith(u8, line, "list_filter ")) return self.applyProtocolCommand(line);
|
|
if (std.mem.startsWith(u8, line, "message ")) {
|
|
self.message = line[8..];
|
|
return;
|
|
}
|
|
if (std.mem.eql(u8, line, "list_down")) return self.applyProtocol("command list_down");
|
|
if (std.mem.eql(u8, line, "list_up")) return self.applyProtocol("command list_up");
|
|
if (std.mem.eql(u8, line, "list_select")) return self.applyProtocol("command list_select");
|
|
if (std.mem.eql(u8, line, "list_cancel")) return self.applyProtocol("command list_cancel");
|
|
if (std.mem.eql(u8, line, "panel_close")) return self.applyProtocol("command panel_close");
|
|
if (std.mem.eql(u8, line, "panel_next")) return self.applyProtocol("command panel_next");
|
|
if (std.mem.eql(u8, line, "panel_prev")) return self.applyProtocol("command panel_prev");
|
|
if (std.mem.startsWith(u8, line, "open ")) return self.applyProtocol(line);
|
|
if (std.mem.startsWith(u8, line, "insert ")) return self.applyProtocolCommand(line);
|
|
if (std.mem.eql(u8, line, "left")) return self.applyProtocol("command move_left");
|
|
if (std.mem.eql(u8, line, "right")) return self.applyProtocol("command move_right");
|
|
if (std.mem.eql(u8, line, "backspace")) return self.applyProtocol("command delete_backward");
|
|
if (std.mem.eql(u8, line, "save")) return self.save();
|
|
if (std.mem.startsWith(u8, line, "resize ")) return self.resize(line[7..]);
|
|
if (std.mem.eql(u8, line, "render")) return;
|
|
|
|
return Error.UnknownTraceEvent;
|
|
}
|
|
|
|
pub fn render(self: *const Client, allocator: std.mem.Allocator) ![]u8 {
|
|
try self.viewport.validate();
|
|
const snap = try self.session.snapshot();
|
|
if (snap.active_panel_title != null) return self.renderPanel(allocator, snap);
|
|
return self.renderEditor(allocator, snap);
|
|
}
|
|
|
|
fn renderEditor(self: *const Client, allocator: std.mem.Allocator, snap: session_mod.Snapshot) ![]u8 {
|
|
var out = std.ArrayList(u8).empty;
|
|
errdefer out.deinit(allocator);
|
|
|
|
const max_body_lines = self.viewport.height - 1;
|
|
const cursor_line = lineIndexAt(snap.bytes, snap.cursor_byte);
|
|
const cursor_col = columnAt(snap.bytes, snap.cursor_byte);
|
|
var visible_line_index: usize = 0;
|
|
var body_lines_used: usize = 0;
|
|
var line_iter = std.mem.splitScalar(u8, snap.bytes, '\n');
|
|
while (line_iter.next()) |line| : (visible_line_index += 1) {
|
|
if (body_lines_used >= max_body_lines) break;
|
|
try appendVisibleCells(allocator, &out, line, self.viewport.width);
|
|
try out.append(allocator, '\n');
|
|
body_lines_used += 1;
|
|
|
|
if (visible_line_index == cursor_line and body_lines_used < max_body_lines) {
|
|
try appendCursorLine(allocator, &out, @min(cursor_col, self.viewport.width - 1), self.viewport.width);
|
|
body_lines_used += 1;
|
|
}
|
|
}
|
|
while (body_lines_used < max_body_lines) : (body_lines_used += 1) {
|
|
try out.append(allocator, '~');
|
|
try out.append(allocator, '\n');
|
|
}
|
|
|
|
const leader_status = self.leader.status();
|
|
const status = if (self.message) |message|
|
|
try std.fmt.allocPrint(allocator, "{s}", .{message})
|
|
else if (leader_status.len != 0)
|
|
try std.fmt.allocPrint(
|
|
allocator,
|
|
"{s}{s}{s}",
|
|
.{ leader_status, if (self.leader.promptText().len > 0) ": " else "", self.leader.promptText() },
|
|
)
|
|
else
|
|
try std.fmt.allocPrint(
|
|
allocator,
|
|
"mim row={d} col={d} bytes={d}{s}",
|
|
.{ cursor_line + 1, cursor_col + 1, snap.bytes.len, if (self.quit) " quit" else "" },
|
|
);
|
|
defer allocator.free(status);
|
|
try appendVisibleCells(allocator, &out, status, self.viewport.width);
|
|
return out.toOwnedSlice(allocator);
|
|
}
|
|
|
|
fn renderPanel(self: *const Client, allocator: std.mem.Allocator, snap: session_mod.Snapshot) ![]u8 {
|
|
var out = std.ArrayList(u8).empty;
|
|
errdefer out.deinit(allocator);
|
|
const max_body_lines = self.viewport.height - 1;
|
|
var body_lines_used: usize = 0;
|
|
|
|
const path = try self.session.panelPathAlloc(allocator);
|
|
defer allocator.free(path);
|
|
const header = try std.fmt.allocPrint(allocator, "panel {s}", .{path});
|
|
defer allocator.free(header);
|
|
try appendVisibleCells(allocator, &out, header, self.viewport.width);
|
|
try out.append(allocator, '\n');
|
|
body_lines_used += 1;
|
|
|
|
const remaining_rows = max_body_lines - body_lines_used;
|
|
const rows = self.session.activeListRowsAlloc(allocator, remaining_rows) catch null;
|
|
if (rows) |list_rows| {
|
|
defer {
|
|
for (list_rows) |row| allocator.free(row);
|
|
allocator.free(list_rows);
|
|
}
|
|
for (list_rows) |row| {
|
|
if (body_lines_used >= max_body_lines) break;
|
|
try appendVisibleCells(allocator, &out, row, self.viewport.width);
|
|
try out.append(allocator, '\n');
|
|
body_lines_used += 1;
|
|
}
|
|
} else if (body_lines_used < max_body_lines) {
|
|
const title = snap.active_panel_title.?;
|
|
const detail = try std.fmt.allocPrint(allocator, "{s}: no content yet", .{title});
|
|
defer allocator.free(detail);
|
|
try appendVisibleCells(allocator, &out, detail, self.viewport.width);
|
|
try out.append(allocator, '\n');
|
|
body_lines_used += 1;
|
|
}
|
|
while (body_lines_used < max_body_lines) : (body_lines_used += 1) {
|
|
try out.append(allocator, '~');
|
|
try out.append(allocator, '\n');
|
|
}
|
|
|
|
const status = if (self.message) |message|
|
|
try std.fmt.allocPrint(allocator, "{s}", .{message})
|
|
else
|
|
try std.fmt.allocPrint(
|
|
allocator,
|
|
"panel {d}/{d} x close",
|
|
.{ snap.active_panel_index.? + 1, snap.panel_depth },
|
|
);
|
|
defer allocator.free(status);
|
|
try appendVisibleCells(allocator, &out, status, self.viewport.width);
|
|
return out.toOwnedSlice(allocator);
|
|
}
|
|
|
|
pub fn handleInput(self: *Client, raw: []const u8) !void {
|
|
if (self.quit) return Error.ClientQuit;
|
|
const event = input.normalize(raw);
|
|
if (self.leader.capturesInput() or isLeaderTrigger(event)) {
|
|
const action = try self.leader.handleEvent(event);
|
|
defer action.deinit(self.allocator);
|
|
try self.applyLeaderAction(action);
|
|
return;
|
|
}
|
|
|
|
_ = try self.leader.handleEvent(event);
|
|
try self.applyNormalInput(event);
|
|
}
|
|
|
|
pub fn saved(self: *const Client) ![]const u8 {
|
|
return self.saved_bytes orelse Error.NothingSaved;
|
|
}
|
|
|
|
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, "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");
|
|
if (std.mem.eql(u8, key_name, "right")) return self.handleInput("\x1b[C");
|
|
if (key_name.len == 1) return self.handleInput(key_name);
|
|
return Error.UnknownTraceEvent;
|
|
}
|
|
|
|
fn isLeaderTrigger(event: input.Event) bool {
|
|
return switch (event) {
|
|
.key => |key| key == .space,
|
|
else => false,
|
|
};
|
|
}
|
|
|
|
fn applyNormalInput(self: *Client, event: input.Event) !void {
|
|
switch (event) {
|
|
.text => |text| {
|
|
const line = try std.fmt.allocPrint(self.allocator, "insert {s}", .{text});
|
|
defer self.allocator.free(line);
|
|
try self.applyProtocolCommand(line);
|
|
},
|
|
.key => |key| switch (key) {
|
|
.backspace => try self.applyProtocol("command delete_backward"),
|
|
.arrow_left => try self.applyProtocol("command move_left"),
|
|
.arrow_right => try self.applyProtocol("command move_right"),
|
|
else => {},
|
|
},
|
|
.unknown => {},
|
|
}
|
|
}
|
|
|
|
fn applyLeaderAction(self: *Client, action: leader_mod.Action) !void {
|
|
switch (action) {
|
|
.none => {},
|
|
.save => try self.save(),
|
|
.quit => self.quit = true,
|
|
.open => |path| {
|
|
const line = try std.fmt.allocPrint(self.allocator, "open {s}", .{path});
|
|
defer self.allocator.free(line);
|
|
try self.applyProtocol(line);
|
|
},
|
|
.symbol => |symbol| try self.applyProtocol(symbol_mod.protocolCommand(symbol)),
|
|
.not_built => {},
|
|
}
|
|
}
|
|
|
|
fn applyProtocolCommand(self: *Client, line: []const u8) !void {
|
|
const command = try std.fmt.allocPrint(self.allocator, "command {s}", .{line});
|
|
defer self.allocator.free(command);
|
|
try self.applyProtocol(command);
|
|
}
|
|
|
|
fn applyProtocol(self: *Client, line: []const u8) !void {
|
|
const response = try protocol.handleLine(self.allocator, &self.session, line);
|
|
defer self.allocator.free(response);
|
|
if (std.mem.startsWith(u8, response, "err ")) {
|
|
self.message = protocolErrorMessage(response);
|
|
return Error.ProtocolRejected;
|
|
}
|
|
self.message = null;
|
|
}
|
|
|
|
fn protocolErrorMessage(response: []const u8) []const u8 {
|
|
if (std.mem.indexOf(u8, response, "invalid panel title") != null) return "error: invalid panel title";
|
|
if (std.mem.indexOf(u8, response, "no panel open") != null) return "error: no panel open";
|
|
if (std.mem.indexOf(u8, response, "invalid list") != null) return "error: invalid list";
|
|
if (std.mem.indexOf(u8, response, "active panel is not list") != null) return "error: active panel is not list";
|
|
return "error: command failed";
|
|
}
|
|
|
|
fn resize(self: *Client, payload: []const u8) !void {
|
|
const x = std.mem.indexOfScalar(u8, payload, 'x') orelse return Error.InvalidResize;
|
|
const width = std.fmt.parseUnsigned(usize, payload[0..x], 10) catch return Error.InvalidResize;
|
|
const height = std.fmt.parseUnsigned(usize, payload[x + 1 ..], 10) catch return Error.InvalidResize;
|
|
const viewport = Viewport{ .width = width, .height = height };
|
|
try viewport.validate();
|
|
self.viewport = viewport;
|
|
}
|
|
|
|
fn save(self: *Client) !void {
|
|
const snap = try self.session.snapshot();
|
|
const copy = try self.allocator.dupe(u8, snap.bytes);
|
|
if (self.saved_bytes) |old| self.allocator.free(old);
|
|
self.saved_bytes = copy;
|
|
self.message = "saved";
|
|
}
|
|
};
|
|
|
|
fn appendVisibleCells(allocator: std.mem.Allocator, out: *std.ArrayList(u8), bytes: []const u8, max_cells: usize) !void {
|
|
var i: usize = 0;
|
|
while (i < bytes.len) {
|
|
const len = std.unicode.utf8ByteSequenceLength(bytes[i]) catch 1;
|
|
const end = @min(bytes.len, i + len);
|
|
const next = bytes[0..end];
|
|
if (session_mod.cellWidth(next) > max_cells) break;
|
|
try out.appendSlice(allocator, bytes[i..end]);
|
|
i = end;
|
|
}
|
|
}
|
|
|
|
fn appendCursorLine(allocator: std.mem.Allocator, out: *std.ArrayList(u8), cursor_col: usize, max_cells: usize) !void {
|
|
var i: usize = 0;
|
|
while (i < cursor_col and i + 1 < max_cells) : (i += 1) try out.append(allocator, ' ');
|
|
try out.append(allocator, '^');
|
|
try out.append(allocator, '\n');
|
|
}
|
|
|
|
fn lineIndexAt(bytes: []const u8, cursor_byte: usize) usize {
|
|
var line: usize = 0;
|
|
for (bytes[0..@min(cursor_byte, bytes.len)]) |byte| {
|
|
if (byte == '\n') line += 1;
|
|
}
|
|
return line;
|
|
}
|
|
|
|
fn columnAt(bytes: []const u8, cursor_byte: usize) usize {
|
|
const prefix = bytes[0..@min(cursor_byte, bytes.len)];
|
|
const line_start = if (std.mem.lastIndexOfScalar(u8, prefix, '\n')) |idx| idx + 1 else 0;
|
|
return session_mod.cellWidth(prefix[line_start..]);
|
|
}
|
|
|
|
fn assertLinesFit(frame: []const u8, width: usize) !void {
|
|
var lines = std.mem.splitScalar(u8, frame, '\n');
|
|
while (lines.next()) |line| {
|
|
try std.testing.expect(session_mod.cellWidth(line) <= width);
|
|
}
|
|
}
|
|
|
|
test "regular: scripted narrow terminal trace edits saves exits and replays saved bytes" {
|
|
const trace =
|
|
\\open abc
|
|
\\right
|
|
\\insert é
|
|
\\save
|
|
\\quit
|
|
\\
|
|
;
|
|
const result = try runTrace(std.testing.allocator, .{ .width = 12, .height = 4 }, trace);
|
|
defer result.deinit(std.testing.allocator);
|
|
try std.testing.expect(result.quit);
|
|
try std.testing.expect(std.mem.indexOf(u8, result.frame, "aébc") != null);
|
|
try assertLinesFit(result.frame, 12);
|
|
try std.testing.expectEqualStrings("aébc", result.saved_bytes.?);
|
|
|
|
const recording =
|
|
\\protocol open abc
|
|
\\protocol command move_right
|
|
\\input insert é
|
|
\\save aébc
|
|
\\
|
|
;
|
|
const replay_result = try replay.replayText(std.testing.allocator, recording);
|
|
defer replay_result.deinit(std.testing.allocator);
|
|
switch (replay_result) {
|
|
.ok => {},
|
|
.diverged => return error.ExpectedReplayOk,
|
|
}
|
|
}
|
|
|
|
test "regular: backspace deletes whole UTF-8 codepoint through terminal client" {
|
|
var client = try Client.init(std.testing.allocator, .{ .width = 10, .height = 3 });
|
|
defer client.deinit();
|
|
|
|
try client.handleTraceLine("open aéb");
|
|
try client.handleTraceLine("right");
|
|
try client.handleTraceLine("right");
|
|
try client.handleTraceLine("backspace");
|
|
try client.handleTraceLine("save");
|
|
|
|
try std.testing.expectEqualStrings("ab", try client.saved());
|
|
}
|
|
|
|
test "regular: resize changes render width without changing buffer bytes" {
|
|
var client = try Client.init(std.testing.allocator, .{ .width = 8, .height = 3 });
|
|
defer client.deinit();
|
|
|
|
try client.handleTraceLine("open abcdefghijk");
|
|
try client.handleTraceLine("resize 5x3");
|
|
const frame = try client.render(std.testing.allocator);
|
|
defer std.testing.allocator.free(frame);
|
|
|
|
try assertLinesFit(frame, 5);
|
|
try client.handleTraceLine("save");
|
|
try std.testing.expectEqualStrings("abcdefghijk", try client.saved());
|
|
}
|
|
|
|
test "adversarial: tiny or malformed resize is rejected" {
|
|
var client = try Client.init(std.testing.allocator, .{ .width = 4, .height = 3 });
|
|
defer client.deinit();
|
|
try client.handleTraceLine("open abc");
|
|
|
|
try std.testing.expectError(Error.ViewportTooSmall, client.handleTraceLine("resize 0x3"));
|
|
try std.testing.expectError(Error.InvalidResize, client.handleTraceLine("resize phone"));
|
|
}
|
|
|
|
test "adversarial: invalid UTF-8 insertion is rejected and previous bytes can still save" {
|
|
var client = try Client.init(std.testing.allocator, .{ .width = 10, .height = 3 });
|
|
defer client.deinit();
|
|
try client.handleTraceLine("open safe");
|
|
|
|
const bad = [_]u8{ 'i', 'n', 's', 'e', 'r', 't', ' ', 0xc3, 0x28 };
|
|
try std.testing.expectError(Error.ProtocolRejected, client.handleTraceLine(&bad));
|
|
try client.handleTraceLine("save");
|
|
try std.testing.expectEqualStrings("safe", try client.saved());
|
|
}
|
|
|
|
test "adversarial: quit prevents further edits" {
|
|
var client = try Client.init(std.testing.allocator, .{ .width = 10, .height = 3 });
|
|
defer client.deinit();
|
|
try client.handleTraceLine("open abc");
|
|
try client.handleTraceLine("quit");
|
|
|
|
try std.testing.expectError(Error.ClientQuit, client.handleTraceLine("insert x"));
|
|
}
|
|
|
|
test "regular: leader rail is visible in terminal render and dispatches save" {
|
|
var client = try Client.init(std.testing.allocator, .{ .width = 64, .height = 4 });
|
|
defer client.deinit();
|
|
|
|
try client.handleTraceLine("open abc");
|
|
try client.handleTraceLine("key space");
|
|
const frame = try client.render(std.testing.allocator);
|
|
defer std.testing.allocator.free(frame);
|
|
try std.testing.expect(std.mem.indexOf(u8, frame, "leader: s save") != null);
|
|
|
|
try client.handleTraceLine("key s");
|
|
try std.testing.expectEqualStrings("abc", try client.saved());
|
|
}
|
|
|
|
test "regular: leader rail dispatches quit" {
|
|
var client = try Client.init(std.testing.allocator, .{ .width = 40, .height = 4 });
|
|
defer client.deinit();
|
|
|
|
try client.handleTraceLine("open abc");
|
|
try client.handleTraceLine("key space");
|
|
try client.handleTraceLine("key q");
|
|
try std.testing.expect(client.quit);
|
|
try std.testing.expectError(Error.ClientQuit, client.handleTraceLine("insert x"));
|
|
}
|
|
|
|
test "regular: leader open prompt opens typed UTF-8 payload" {
|
|
var client = try Client.init(std.testing.allocator, .{ .width = 64, .height = 4 });
|
|
defer client.deinit();
|
|
|
|
try client.handleTraceLine("open old");
|
|
try client.handleTraceLine("key space");
|
|
try client.handleTraceLine("key o");
|
|
try client.handleTraceLine("type café.zig");
|
|
const frame = try client.render(std.testing.allocator);
|
|
defer std.testing.allocator.free(frame);
|
|
try std.testing.expect(std.mem.indexOf(u8, frame, "café.zig") != null);
|
|
|
|
try client.handleTraceLine("key enter");
|
|
try client.handleTraceLine("key space");
|
|
try client.handleTraceLine("key s");
|
|
try std.testing.expectEqualStrings("café.zig", try client.saved());
|
|
}
|
|
|
|
test "regular: leader search and panel-close entries recover as not-built messages" {
|
|
var client = try Client.init(std.testing.allocator, .{ .width = 64, .height = 4 });
|
|
defer client.deinit();
|
|
try client.handleTraceLine("open abc");
|
|
|
|
try client.handleTraceLine("key space");
|
|
try client.handleTraceLine("key /");
|
|
const search_frame = try client.render(std.testing.allocator);
|
|
defer std.testing.allocator.free(search_frame);
|
|
try std.testing.expect(std.mem.indexOf(u8, search_frame, "search is not built") != null);
|
|
|
|
try client.handleTraceLine("key space");
|
|
try client.handleTraceLine("key x");
|
|
const close_frame = try client.render(std.testing.allocator);
|
|
defer std.testing.allocator.free(close_frame);
|
|
try std.testing.expect(std.mem.indexOf(u8, close_frame, "panel close is not built") != null);
|
|
}
|
|
|
|
test "adversarial: unknown leader input recovers without saving or quitting" {
|
|
var client = try Client.init(std.testing.allocator, .{ .width = 48, .height = 4 });
|
|
defer client.deinit();
|
|
try client.handleTraceLine("open abc");
|
|
|
|
try client.handleTraceLine("key space");
|
|
try client.handleTraceLine("key ?");
|
|
const frame = try client.render(std.testing.allocator);
|
|
defer std.testing.allocator.free(frame);
|
|
try std.testing.expect(std.mem.indexOf(u8, frame, "unknown leader key") != null);
|
|
try std.testing.expect(!client.quit);
|
|
try std.testing.expectError(Error.NothingSaved, client.saved());
|
|
}
|
|
|
|
fn expectSymbolTrace(symbol_key: []const u8, expected_bytes: []const u8, expected_cursor_byte: usize) !void {
|
|
var client = try Client.init(std.testing.allocator, .{ .width = 64, .height = 4 });
|
|
defer client.deinit();
|
|
|
|
try client.handleTraceLine("open base");
|
|
try client.handleTraceLine("key space");
|
|
try client.handleTraceLine("key p");
|
|
const rail = try client.render(std.testing.allocator);
|
|
defer std.testing.allocator.free(rail);
|
|
try std.testing.expect(std.mem.indexOf(u8, rail, "symbols:") != null);
|
|
|
|
const line = try std.fmt.allocPrint(std.testing.allocator, "key {s}", .{symbol_key});
|
|
defer std.testing.allocator.free(line);
|
|
try client.handleTraceLine(line);
|
|
|
|
const snap = try client.session.snapshot();
|
|
try std.testing.expectEqualStrings(expected_bytes, snap.bytes);
|
|
try std.testing.expectEqual(expected_cursor_byte, snap.cursor_byte);
|
|
}
|
|
|
|
test "regular: symbol rail inserts paired braces brackets parens and quotes with cursor inside" {
|
|
try expectSymbolTrace("p", "()base", 1);
|
|
try expectSymbolTrace("b", "[]base", 1);
|
|
try expectSymbolTrace("c", "{}base", 1);
|
|
try expectSymbolTrace("q", "\"\"base", 1);
|
|
try expectSymbolTrace("e", "''base", 1);
|
|
try expectSymbolTrace("t", "``base", 1);
|
|
}
|
|
|
|
test "regular: symbol rail inserts slash pipe and underscore as single symbols" {
|
|
try expectSymbolTrace("s", "/base", 1);
|
|
try expectSymbolTrace("v", "|base", 1);
|
|
try expectSymbolTrace("u", "_base", 1);
|
|
}
|
|
|
|
test "adversarial: unknown symbol key does not mutate the buffer" {
|
|
var client = try Client.init(std.testing.allocator, .{ .width = 64, .height = 4 });
|
|
defer client.deinit();
|
|
|
|
try client.handleTraceLine("open base");
|
|
try client.handleTraceLine("key space");
|
|
try client.handleTraceLine("key p");
|
|
try client.handleTraceLine("key ?");
|
|
|
|
const snap = try client.session.snapshot();
|
|
try std.testing.expectEqualStrings("base", snap.bytes);
|
|
const frame = try client.render(std.testing.allocator);
|
|
defer std.testing.allocator.free(frame);
|
|
try std.testing.expect(std.mem.indexOf(u8, frame, "unknown symbol key") != null);
|
|
}
|
|
|
|
test "regular: narrow terminal renders active panel instead of editor and returns cleanly" {
|
|
var client = try Client.init(std.testing.allocator, .{ .width = 20, .height = 4 });
|
|
defer client.deinit();
|
|
|
|
try client.handleTraceLine("open editor-text");
|
|
try client.handleTraceLine("panel_open files");
|
|
const frame = try client.render(std.testing.allocator);
|
|
defer std.testing.allocator.free(frame);
|
|
try std.testing.expect(std.mem.indexOf(u8, frame, "panel [files]") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, frame, "editor-text") == null);
|
|
try assertLinesFit(frame, 20);
|
|
|
|
try client.handleTraceLine("panel_close");
|
|
const editor_frame = try client.render(std.testing.allocator);
|
|
defer std.testing.allocator.free(editor_frame);
|
|
try std.testing.expect(std.mem.indexOf(u8, editor_frame, "editor-text") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, editor_frame, "panel [files]") == null);
|
|
try assertLinesFit(editor_frame, 20);
|
|
}
|
|
|
|
test "regular: nested panel breadcrumbs render on narrow terminal without overflow" {
|
|
var client = try Client.init(std.testing.allocator, .{ .width = 18, .height = 4 });
|
|
defer client.deinit();
|
|
|
|
try client.handleTraceLine("open abc");
|
|
try client.handleTraceLine("panel_open files");
|
|
try client.handleTraceLine("panel_open diagnostics");
|
|
const frame = try client.render(std.testing.allocator);
|
|
defer std.testing.allocator.free(frame);
|
|
try std.testing.expect(std.mem.indexOf(u8, frame, "panel files>") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, frame, "[diagnostics]") == null);
|
|
try assertLinesFit(frame, 18);
|
|
|
|
try client.handleTraceLine("panel_prev");
|
|
const previous_frame = try client.render(std.testing.allocator);
|
|
defer std.testing.allocator.free(previous_frame);
|
|
try std.testing.expect(std.mem.indexOf(u8, previous_frame, "panel [files]") != null);
|
|
try assertLinesFit(previous_frame, 18);
|
|
}
|
|
|
|
test "adversarial: invalid panel title and empty close recover without changing editor render" {
|
|
var client = try Client.init(std.testing.allocator, .{ .width = 16, .height = 4 });
|
|
defer client.deinit();
|
|
|
|
try client.handleTraceLine("open abc");
|
|
try std.testing.expectError(Error.ProtocolRejected, client.handleTraceLine("panel_open bad title"));
|
|
try std.testing.expectError(Error.ProtocolRejected, client.handleTraceLine("panel_close"));
|
|
const frame = try client.render(std.testing.allocator);
|
|
defer std.testing.allocator.free(frame);
|
|
try std.testing.expect(std.mem.indexOf(u8, frame, "abc") != null);
|
|
try assertLinesFit(frame, 16);
|
|
}
|
|
|
|
test "regular: list panel filters moves selects and renders rows" {
|
|
var client = try Client.init(std.testing.allocator, .{ .width = 32, .height = 5 });
|
|
defer client.deinit();
|
|
|
|
try client.handleTraceLine("list_open files src/main.zig|src/panel.zig|README.md");
|
|
try client.handleTraceLine("list_filter src");
|
|
try client.handleTraceLine("list_down");
|
|
const frame = try client.render(std.testing.allocator);
|
|
defer std.testing.allocator.free(frame);
|
|
try std.testing.expect(std.mem.indexOf(u8, frame, "panel [files]") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, frame, "> src/panel.zig") != null);
|
|
try assertLinesFit(frame, 32);
|
|
|
|
try client.handleTraceLine("list_select");
|
|
const summary = try client.session.panelSummaryAlloc(std.testing.allocator);
|
|
defer std.testing.allocator.free(summary);
|
|
try std.testing.expectEqualStrings("list:filter=src,cursor=1,visible=2,selected=src/panel.zig", summary);
|
|
}
|
|
|
|
test "regular: list cancel returns to previous editor surface" {
|
|
var client = try Client.init(std.testing.allocator, .{ .width = 24, .height = 4 });
|
|
defer client.deinit();
|
|
|
|
try client.handleTraceLine("open abc");
|
|
try client.handleTraceLine("list_open files a.zig|b.zig");
|
|
try client.handleTraceLine("list_cancel");
|
|
const frame = try client.render(std.testing.allocator);
|
|
defer std.testing.allocator.free(frame);
|
|
try std.testing.expect(std.mem.indexOf(u8, frame, "abc") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, frame, "panel") == null);
|
|
}
|
|
|
|
test "adversarial: list no-match state and invalid actions recover" {
|
|
var client = try Client.init(std.testing.allocator, .{ .width = 24, .height = 4 });
|
|
defer client.deinit();
|
|
|
|
try client.handleTraceLine("list_open files a.zig|b.zig");
|
|
try client.handleTraceLine("list_filter none");
|
|
const frame = try client.render(std.testing.allocator);
|
|
defer std.testing.allocator.free(frame);
|
|
try std.testing.expect(std.mem.indexOf(u8, frame, "no matches") != null);
|
|
try assertLinesFit(frame, 24);
|
|
|
|
try client.handleTraceLine("list_cancel");
|
|
try std.testing.expectError(Error.ProtocolRejected, client.handleTraceLine("list_down"));
|
|
}
|
|
|
|
test "regular: save status appears near editor status line" {
|
|
var client = try Client.init(std.testing.allocator, .{ .width = 20, .height = 4 });
|
|
defer client.deinit();
|
|
|
|
try client.handleTraceLine("open abc");
|
|
try client.handleTraceLine("key space");
|
|
try client.handleTraceLine("key s");
|
|
const frame = try client.render(std.testing.allocator);
|
|
defer std.testing.allocator.free(frame);
|
|
try std.testing.expect(std.mem.indexOf(u8, frame, "saved") != null);
|
|
try assertLinesFit(frame, 20);
|
|
}
|
|
|
|
test "regular: empty generic panel shows local empty state" {
|
|
var client = try Client.init(std.testing.allocator, .{ .width = 22, .height = 4 });
|
|
defer client.deinit();
|
|
|
|
try client.handleTraceLine("panel_open help");
|
|
const frame = try client.render(std.testing.allocator);
|
|
defer std.testing.allocator.free(frame);
|
|
try std.testing.expect(std.mem.indexOf(u8, frame, "help: no content yet") != null);
|
|
try assertLinesFit(frame, 22);
|
|
}
|
|
|
|
test "adversarial: protocol errors render recoverable local message" {
|
|
var client = try Client.init(std.testing.allocator, .{ .width = 24, .height = 4 });
|
|
defer client.deinit();
|
|
|
|
try client.handleTraceLine("open abc");
|
|
try std.testing.expectError(Error.ProtocolRejected, client.handleTraceLine("panel_open bad title"));
|
|
const frame = try client.render(std.testing.allocator);
|
|
defer std.testing.allocator.free(frame);
|
|
try std.testing.expect(std.mem.indexOf(u8, frame, "error: invalid panel") != null);
|
|
try assertLinesFit(frame, 24);
|
|
}
|
|
|
|
test "adversarial: long feedback messages are clipped to viewport width" {
|
|
var client = try Client.init(std.testing.allocator, .{ .width = 16, .height = 4 });
|
|
defer client.deinit();
|
|
|
|
try client.handleTraceLine("open abc");
|
|
try client.handleTraceLine("message this-message-is-way-too-long-for-the-phone-width");
|
|
const frame = try client.render(std.testing.allocator);
|
|
defer std.testing.allocator.free(frame);
|
|
try assertLinesFit(frame, 16);
|
|
try std.testing.expect(std.mem.indexOf(u8, frame, "this-message-is-") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, frame, "phone-width") == null);
|
|
}
|