src/tui/wall_test_harness.zig
Ref: Size: 10.7 KiB History
//! Fixtures the wall's test files share: benches that stand a wall up
//! without a terminal, and the transports and screens they read back.
const std = @import("std");
const proto = @import("term").protocol;
const client = @import("client");
const Engine = @import("engine").Engine;
const layout = @import("client").layout;
const wall_host = @import("wall_host.zig");
const wall_picker = @import("wall_picker.zig");
const wall_pump = @import("wall_pump.zig");
const wv = @import("wallview.zig");
const EndReason = wv.EndReason;
const Host = wall_host.Host;
const Shared = wv.Shared;
const Tile = wv.Tile;
/// The `Wall` the product passes around, from a test's own arrays.
pub fn wallOf(
alloc: std.mem.Allocator,
tiles: []Tile,
present: []bool,
live: *usize,
shared: *Shared,
hosts: []Host,
) wv.Wall {
return .{ .alloc = alloc, .tiles = tiles, .present = present, .live = live, .shared = shared, .hosts = hosts };
}
/// The cell `wallLive` points at. File-scope because a layout test builds
/// its wall per call and Zig runs the tests in one process, one at a time;
/// nothing here births, so nothing writes it but the call being made. The
/// nearer trap is intra-test: two `wallLive` calls with different `live`
/// share this cell, and the first wall then reads the second's count.
var standing_live: usize = 0;
/// A wall of `live` slots over arrays a test declared, with no hosts: what
/// the layout and focus calls take, none of which grows the wall.
fn wallLive(alloc: std.mem.Allocator, tiles: []Tile, present: []bool, live: usize, shared: *Shared) wv.Wall {
standing_live = live;
return wallOf(alloc, tiles, present, &standing_live, shared, &.{});
}
/// `wallLive` where every slot the test declared is on the wall.
pub fn wallAll(alloc: std.mem.Allocator, tiles: []Tile, present: []bool, shared: *Shared) wv.Wall {
return wallLive(alloc, tiles, present, tiles.len, shared);
}
/// Three tiles: two sessions on host 0 and a same-named one on host 1, so a
/// diff that aliased by NAME alone would be caught.
pub fn diffFixture(shared: *Shared) [3]Tile {
const t0: client.Target = .{ .sock = "/tmp/h0.sock" };
const t1: client.Target = .{ .sock = "/tmp/h1.sock" };
const rect: layout.Rect = .{ .top = 0, .left = 0, .rows = 24, .cols = 80 };
return .{
.{
.r = .{ .target = t0, .label = "--sock /tmp/h0.sock#a", .session = "a" },
.rect = rect,
.shared = shared,
.idx = 0,
.wake_r = -1,
.wake_w = -1,
.host = 0,
},
.{
.r = .{ .target = t0, .label = "--sock /tmp/h0.sock#b", .session = "b" },
.rect = rect,
.shared = shared,
.idx = 1,
.wake_r = -1,
.wake_w = -1,
.host = 0,
},
.{
.r = .{ .target = t1, .label = "--sock /tmp/h1.sock#a", .session = "a" },
.rect = rect,
.shared = shared,
.idx = 2,
.wake_r = -1,
.wake_w = -1,
.host = 1,
},
};
}
/// The keyboard-thread fixture: `running` stopped, so a pump a diff spawns
/// returns at its first check rather than dialling a socket no test is
/// listening on.
pub fn stoppedWall(alloc: std.mem.Allocator, shared: *Shared) void {
shared.* = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
shared.tree = layout.Tree.init(alloc);
shared.flat_alloc = alloc;
shared.running.store(false, .release);
}
/// Detached pumps hold `*Tile` into the test's own frame; nothing may return
/// while one is still running. The wake pipes go here too: the WALL never
/// closes one (see `ring`), and a test binary would run out of fds.
pub fn endPumps(tiles: []Tile) void {
for (tiles) |*t| {
while (t.alive.load(.acquire)) std.Thread.sleep(std.time.ns_per_ms);
}
for (tiles) |*t| {
if (t.wake_r >= 0) std.posix.close(t.wake_r);
if (t.wake_w >= 0) std.posix.close(t.wake_w);
t.wake_r = -1;
t.wake_w = -1;
}
}
pub fn testHost(shared: *Shared, spelling: []const u8, sock: []const u8) Host {
return .{
.spec = .{ .spelling = spelling, .target = .{ .sock = sock }, .poll_target = .{ .sock = sock } },
.shared = shared,
};
}
pub fn setList(h: *Host, list: []const u8) void {
@memcpy(h.poll.list[0..list.len], list);
h.poll.list_len = list.len;
h.poll.reachable.store(true, .release);
}
/// A wall of hosts and no tiles: what a test about the BOX rather than the
/// panes under it paints from. `hostWall`'s own cell, not `standing_live`,
/// so a test that already stood a wall up does not have its count zeroed by
/// painting a popup over it.
var host_wall_live: usize = 0;
var no_tiles: [0]Tile = .{};
var no_present: [0]bool = .{};
pub fn hostWall(shared: *Shared, host_table: []Host) wv.Wall {
host_wall_live = 0;
return wallOf(std.testing.allocator, &no_tiles, &no_present, &host_wall_live, shared, host_table);
}
// A picker painted into a pipe, drained. Non-blocking on both ends so a
// frame that outgrew the pipe FAILS here rather than parking the suite.
pub fn pickerFrame(shared: *Shared, r: std.posix.fd_t, host_table: []Host, sel: usize, out: []u8) []const u8 {
return pickerFrameAt(shared, r, host_table, sel, .{}, out);
}
/// `pickerFrame` at a stated level and row: the session list is a second
/// body out of the same box, and the paint is what a test can judge.
pub fn pickerFrameAt(
shared: *Shared,
r: std.posix.fd_t,
host_table: []Host,
sel: usize,
view: wall_picker.PickerView,
out: []u8,
) []const u8 {
wall_picker.paintPicker(hostWall(shared, host_table), sel, view, null);
const n = std.posix.read(r, out) catch 0;
return out[0..n];
}
pub fn claimBench(shared: *Shared, idx: usize) Tile {
return .{
.r = .{ .target = .{ .sock = "/tmp/x" }, .label = "x", .session = "0" },
.rect = .{ .top = 0, .left = 0, .rows = 24, .cols = 80 },
.shared = shared,
.idx = idx,
.wake_r = -1,
.wake_w = -1,
};
}
/// Only the fields `endAction` reads: a whole Tile describes the pump, not
/// the rule.
pub fn endBench(
tiles: []Tile,
shared: *Shared,
ended: usize,
reason: EndReason,
code: u8,
born_from: ?usize,
) void {
for (tiles, 0..) |*t, i| {
t.* = .{
.r = .{ .target = .{ .sock = "/s" }, .label = "t", .session = "" },
.rect = .{ .top = 0, .left = 0, .rows = 2, .cols = 80 },
.shared = shared,
.idx = i,
};
}
tiles[ended].end.store(@intFromEnum(reason), .release);
tiles[ended].code.store(code, .release);
tiles[ended].born_from = born_from;
}
// A pump, reduced to the one thing this test judges: it takes passes, and
// remembers the content size the pass told it to send.
pub const ResizeWitness = struct {
t: *Tile,
stop: std.atomic.Value(bool) = std.atomic.Value(bool).init(false),
sent: std.atomic.Value(u32) = std.atomic.Value(u32).init(0),
passes: std.atomic.Value(u64) = std.atomic.Value(u64).init(0),
pub fn run(w: *ResizeWitness) void {
while (!w.stop.load(.acquire)) {
const p = wall_pump.takePass(w.t);
if (p.resize) w.sent.store(pack(p.cols, p.rows -| p.label_rows), .release);
_ = w.passes.fetchAdd(1, .release);
}
}
pub fn pack(cols: u16, rows: u16) u32 {
return (@as(u32, cols) << 16) | @as(u32, rows);
}
};
// The terminal, as an oracle. The wall's bytes are replayed into an engine
// and the GRID is judged: a bar painted at a row the terminal no longer has
// looks fine in the bytes — the terminal clamps the CUP onto its last row —
// and lands on top of somebody else's content in the grid.
pub const WallScreen = struct {
eng: *Engine,
r: std.posix.fd_t,
w: std.posix.fd_t,
pub fn init(alloc: std.mem.Allocator, cols: u16, rows: u16) !WallScreen {
const p = try std.posix.pipe2(.{ .NONBLOCK = true });
return .{
.eng = try Engine.init(alloc, .{ .cols = cols, .rows = rows }),
.r = p[0],
.w = p[1],
};
}
pub fn deinit(self: *WallScreen) void {
self.eng.deinit();
std.posix.close(self.r);
std.posix.close(self.w);
}
pub fn drain(self: *WallScreen) void {
var buf: [4096]u8 = undefined;
while (std.posix.read(self.r, &buf)) |n| {
if (n == 0) break;
self.eng.feed(buf[0..n]);
} else |_| {}
}
pub fn line(text: []const u8, n: u16) ?[]const u8 {
var it = std.mem.splitScalar(u8, text, '\n');
var i: u16 = 0;
while (it.next()) |l| : (i += 1) if (i == n) return l;
return null;
}
};
pub fn noticeBench(shared: *Shared, tiles: []Tile) void {
for (tiles, 0..) |*t, i| {
t.* = Tile{
.r = .{ .target = .{ .sock = "/tmp/x" }, .label = "x", .session = "" },
.rect = .{ .top = 0, .left = 0, .rows = 24, .cols = 80 },
.shared = shared,
.idx = i,
.wake_r = -1,
.wake_w = -1,
};
}
}
// A transport that swallows every frame: a Core's selection request goes
// nowhere, which is what lets a copy test stand up without a daemon.
pub const SwallowTransport = struct {
pub fn writeFrame(_: *SwallowTransport, _: proto.MsgType, _: []const u8) !void {}
};
// One selection_reply payload — id, status, watermark, text — in the wire
// form encodeSelectionReply writes, laid out in a caller buffer so a test
// does not allocate the prefix separately.
pub fn replyBytes(
buf: []u8,
id: u32,
status: proto.SelectionStatus,
history_rows: u32,
text: []const u8,
) []const u8 {
std.mem.writeInt(u32, buf[0..4], id, .little);
buf[4] = @intFromEnum(status);
std.mem.writeInt(u32, buf[5..9], history_rows, .little);
@memset(buf[9..proto.selection_reply_prefix_len], 0);
@memcpy(buf[proto.selection_reply_prefix_len..][0..text.len], text);
return buf[0 .. proto.selection_reply_prefix_len + text.len];
}
/// Whether the other end of a non-blocking pipe has been closed: EOF says
/// yes, EAGAIN says the writer is still holding it.
pub fn peerClosed(fd: std.posix.fd_t) bool {
var b: [1]u8 = undefined;
const n = std.posix.read(fd, &b) catch return false;
return n == 0;
}
/// Everything waiting on a non-blocking pipe. Non-blocking so a half that
/// stopped writing fails the assertion rather than parking the test on a
/// read that will never return.
pub fn readAvail(fd: std.posix.fd_t, buf: []u8) []const u8 {
var n: usize = 0;
while (std.posix.read(fd, buf[n..])) |got| {
if (got == 0) break;
n += got;
} else |_| {}
return buf[0..n];
}