a73x

5e74a908

refactor: every wall test sits beside the code it names

a73x   2026-08-28 20:56

Commit message
refactor: every wall test sits beside the code it names

All 115 tests, same names, split by subject into wall_test_host,
wall_test_picker, wall_test_pump, wall_test_layout and wall_test_wall,
with the benches and stand-in transports they share in
wall_test_harness. wallview.zig reaches them the way server.zig reaches
its own.

docscheck.budget
Old New
@@ -52,3 +52,9 @@ wall_host.zig 0
52 wall_picker.zig 0 52 wall_picker.zig 0
53 wall_pump.zig 0 53 wall_pump.zig 0
54 wall_layout.zig 1596 54 wall_layout.zig 1596
55 wall_test_harness.zig 0
56 wall_test_host.zig 0
57 wall_test_picker.zig 0
58 wall_test_pump.zig 0
59 wall_test_layout.zig 0
60 wall_test_wall.zig 0
src/tui/wall_test_harness.zig
Old New
@@ -0,0 +1,254 @@
1 //! Fixtures the wall's test files share: benches that stand a wall up
2 //! without a terminal, and the transports and screens they read back.
3 const std = @import("std");
4 const proto = @import("protocol");
5 const client = @import("client");
6 const Engine = @import("engine").Engine;
7 const layout = @import("layout");
8 const wall_host = @import("wall_host.zig");
9 const wall_picker = @import("wall_picker.zig");
10 const wall_pump = @import("wall_pump.zig");
11 const wv = @import("wallview.zig");
12 const EndReason = wv.EndReason;
13 const Host = wall_host.Host;
14 const Shared = wv.Shared;
15 const Tile = wv.Tile;
16
17 /// Three tiles: two sessions on host 0 and a same-named one on host 1, so a
18 /// diff that aliased by NAME alone would be caught.
19 pub fn diffFixture(shared: *Shared) [3]Tile {
20 const t0: client.Target = .{ .sock = "/tmp/h0.sock" };
21 const t1: client.Target = .{ .sock = "/tmp/h1.sock" };
22 const rect: layout.Rect = .{ .top = 0, .left = 0, .rows = 24, .cols = 80 };
23 return .{
24 .{
25 .r = .{ .target = t0, .label = "--sock /tmp/h0.sock#a", .session = "a" },
26 .rect = rect,
27 .shared = shared,
28 .idx = 0,
29 .wake_r = -1,
30 .wake_w = -1,
31 .host = 0,
32 },
33 .{
34 .r = .{ .target = t0, .label = "--sock /tmp/h0.sock#b", .session = "b" },
35 .rect = rect,
36 .shared = shared,
37 .idx = 1,
38 .wake_r = -1,
39 .wake_w = -1,
40 .host = 0,
41 },
42 .{
43 .r = .{ .target = t1, .label = "--sock /tmp/h1.sock#a", .session = "a" },
44 .rect = rect,
45 .shared = shared,
46 .idx = 2,
47 .wake_r = -1,
48 .wake_w = -1,
49 .host = 1,
50 },
51 };
52 }
53
54 /// The keyboard-thread fixture: `running` stopped, so a pump a diff spawns
55 /// returns at its first check rather than dialling a socket no test is
56 /// listening on.
57 pub fn stoppedWall(alloc: std.mem.Allocator, shared: *Shared) void {
58 shared.* = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
59 shared.tree = layout.Tree.init(alloc);
60 shared.flat_alloc = alloc;
61 shared.running.store(false, .release);
62 }
63
64 /// Detached pumps hold `*Tile` into the test's own frame; nothing may return
65 /// while one is still running. The wake pipes go here too: the WALL never
66 /// closes one (see `ring`), and a test binary would run out of fds.
67 pub fn endPumps(tiles: []Tile) void {
68 for (tiles) |*t| {
69 while (t.alive.load(.acquire)) std.Thread.sleep(std.time.ns_per_ms);
70 }
71 for (tiles) |*t| {
72 if (t.wake_r >= 0) std.posix.close(t.wake_r);
73 if (t.wake_w >= 0) std.posix.close(t.wake_w);
74 t.wake_r = -1;
75 t.wake_w = -1;
76 }
77 }
78
79 pub fn testHost(shared: *Shared, spelling: []const u8, sock: []const u8) Host {
80 return .{
81 .spec = .{ .spelling = spelling, .target = .{ .sock = sock }, .poll_target = .{ .sock = sock } },
82 .shared = shared,
83 };
84 }
85
86 pub fn setList(h: *Host, list: []const u8) void {
87 @memcpy(h.list[0..list.len], list);
88 h.list_len = list.len;
89 h.reachable.store(true, .release);
90 }
91
92 // A picker painted into a pipe, drained. Non-blocking on both ends so a
93 // frame that outgrew the pipe FAILS here rather than parking the suite.
94 pub fn pickerFrame(shared: *Shared, r: std.posix.fd_t, host_table: []Host, sel: usize, out: []u8) []const u8 {
95 wall_picker.paintPicker(shared, host_table, sel, null);
96 const n = std.posix.read(r, out) catch 0;
97 return out[0..n];
98 }
99
100 pub fn claimBench(shared: *Shared, idx: usize) Tile {
101 return .{
102 .r = .{ .target = .{ .sock = "/tmp/x" }, .label = "x", .session = "0" },
103 .rect = .{ .top = 0, .left = 0, .rows = 24, .cols = 80 },
104 .shared = shared,
105 .idx = idx,
106 .wake_r = -1,
107 .wake_w = -1,
108 };
109 }
110
111 /// A wall of `n` tiles with none forgotten.
112 fn allPresent(comptime n: usize) [n]bool {
113 return [_]bool{true} ** n;
114 }
115
116 /// Only the fields `endAction` reads: a whole Tile describes the pump, not
117 /// the rule.
118 pub fn endBench(
119 tiles: []Tile,
120 shared: *Shared,
121 ended: usize,
122 reason: EndReason,
123 code: u8,
124 born_from: ?usize,
125 ) void {
126 for (tiles, 0..) |*t, i| {
127 t.* = .{
128 .r = .{ .target = .{ .sock = "/s" }, .label = "t", .session = "" },
129 .rect = .{ .top = 0, .left = 0, .rows = 2, .cols = 80 },
130 .shared = shared,
131 .idx = i,
132 };
133 }
134 tiles[ended].end.store(@intFromEnum(reason), .release);
135 tiles[ended].code.store(code, .release);
136 tiles[ended].born_from = born_from;
137 }
138
139 // A pump, reduced to the one thing this test judges: it takes passes, and
140 // remembers the content size the pass told it to send.
141 pub const ResizeWitness = struct {
142 t: *Tile,
143 stop: std.atomic.Value(bool) = std.atomic.Value(bool).init(false),
144 sent: std.atomic.Value(u32) = std.atomic.Value(u32).init(0),
145 passes: std.atomic.Value(u64) = std.atomic.Value(u64).init(0),
146
147 pub fn run(w: *ResizeWitness) void {
148 while (!w.stop.load(.acquire)) {
149 const p = wall_pump.takePass(w.t);
150 if (p.resize) w.sent.store(pack(p.cols, p.rows -| p.label_rows), .release);
151 _ = w.passes.fetchAdd(1, .release);
152 }
153 }
154
155 pub fn pack(cols: u16, rows: u16) u32 {
156 return (@as(u32, cols) << 16) | @as(u32, rows);
157 }
158 };
159
160 // The terminal, as an oracle. The wall's bytes are replayed into an engine
161 // and the GRID is judged: a bar painted at a row the terminal no longer has
162 // looks fine in the bytes — the terminal clamps the CUP onto its last row —
163 // and lands on top of somebody else's content in the grid.
164 pub const WallScreen = struct {
165 eng: *Engine,
166 r: std.posix.fd_t,
167 w: std.posix.fd_t,
168
169 pub fn init(alloc: std.mem.Allocator, cols: u16, rows: u16) !WallScreen {
170 const p = try std.posix.pipe2(.{ .NONBLOCK = true });
171 return .{
172 .eng = try Engine.init(alloc, .{ .cols = cols, .rows = rows }),
173 .r = p[0],
174 .w = p[1],
175 };
176 }
177
178 pub fn deinit(self: *WallScreen) void {
179 self.eng.deinit();
180 std.posix.close(self.r);
181 std.posix.close(self.w);
182 }
183
184 pub fn drain(self: *WallScreen) void {
185 var buf: [4096]u8 = undefined;
186 while (std.posix.read(self.r, &buf)) |n| {
187 if (n == 0) break;
188 self.eng.feed(buf[0..n]);
189 } else |_| {}
190 }
191
192 pub fn line(text: []const u8, n: u16) ?[]const u8 {
193 var it = std.mem.splitScalar(u8, text, '\n');
194 var i: u16 = 0;
195 while (it.next()) |l| : (i += 1) if (i == n) return l;
196 return null;
197 }
198 };
199
200 pub fn noticeBench(shared: *Shared, tiles: []Tile) void {
201 for (tiles, 0..) |*t, i| {
202 t.* = Tile{
203 .r = .{ .target = .{ .sock = "/tmp/x" }, .label = "x", .session = "" },
204 .rect = .{ .top = 0, .left = 0, .rows = 24, .cols = 80 },
205 .shared = shared,
206 .idx = i,
207 .wake_r = -1,
208 .wake_w = -1,
209 };
210 }
211 }
212
213 // A transport that swallows every frame: a Core's selection request goes
214 // nowhere, which is what lets a copy test stand up without a daemon.
215 pub const SwallowTransport = struct {
216 pub fn writeFrame(_: *SwallowTransport, _: proto.MsgType, _: []const u8) !void {}
217 };
218
219 // One selection_reply payload — id, status, watermark, text — in the wire
220 // form encodeSelectionReply writes, laid out in a caller buffer so a test
221 // does not allocate the prefix separately.
222 pub fn replyBytes(
223 buf: []u8,
224 id: u32,
225 status: proto.SelectionStatus,
226 history_rows: u32,
227 text: []const u8,
228 ) []const u8 {
229 std.mem.writeInt(u32, buf[0..4], id, .little);
230 buf[4] = @intFromEnum(status);
231 std.mem.writeInt(u32, buf[5..9], history_rows, .little);
232 @memcpy(buf[proto.selection_reply_prefix_len..][0..text.len], text);
233 return buf[0 .. proto.selection_reply_prefix_len + text.len];
234 }
235
236 /// Whether the other end of a non-blocking pipe has been closed: EOF says
237 /// yes, EAGAIN says the writer is still holding it.
238 pub fn peerClosed(fd: std.posix.fd_t) bool {
239 var b: [1]u8 = undefined;
240 const n = std.posix.read(fd, &b) catch return false;
241 return n == 0;
242 }
243
244 /// Everything waiting on a non-blocking pipe. Non-blocking so a half that
245 /// stopped writing fails the assertion rather than parking the test on a
246 /// read that will never return.
247 pub fn readAvail(fd: std.posix.fd_t, buf: []u8) []const u8 {
248 var n: usize = 0;
249 while (std.posix.read(fd, buf[n..])) |got| {
250 if (got == 0) break;
251 n += got;
252 } else |_| {}
253 return buf[0..n];
254 }
src/tui/wall_test_host.zig
Old New
@@ -0,0 +1,633 @@
1 //! The hosts file, the host table and the poller (wall_host.zig).
2 const std = @import("std");
3 const proto = @import("protocol");
4 const wall = @import("wall");
5 const hosts = @import("hosts");
6 const TmpDir = @import("testtmp").TmpDir;
7 const fixture = @import("wall_test_harness.zig");
8 const wall_host = @import("wall_host.zig");
9 const wv = @import("wallview.zig");
10 const AddHost = wall_host.AddHost;
11 const BirthNames = wall_host.BirthNames;
12 const Host = wall_host.Host;
13 const HostSpec = wall_host.HostSpec;
14 const Shared = wv.Shared;
15 const Tile = wv.Tile;
16 const TileIdxs = wall_host.TileIdxs;
17
18 test "planHostDiff: names the daemon has and the wall does not are births, tiles it dropped vanish, and another host's same name is untouched" {
19 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
20 var tiles = fixture.diffFixture(&shared);
21 var present = [_]bool{ true, true, true };
22
23 var births = BirthNames{};
24 var vanish = TileIdxs{};
25 // The birth is immediate; the vanish waits for a second list to agree
26 // (see the grace test below), so this list is asked twice.
27 wall_host.planHostDiff(&tiles, &present, 3, 0, "a\nc\n", null, &births, &vanish);
28 try std.testing.expectEqual(@as(usize, 1), births.len);
29 try std.testing.expectEqualStrings("c", births.get(0));
30 births.len = 0;
31 wall_host.planHostDiff(&tiles, &present, 3, 0, "a\nc\n", null, &births, &vanish);
32 try std.testing.expectEqual(@as(usize, 1), vanish.len);
33 try std.testing.expectEqual(@as(usize, 1), vanish.get(0));
34
35 // Host 1's "a" survives host 0's list saying nothing about it.
36 births.len = 0;
37 vanish.len = 0;
38 wall_host.planHostDiff(&tiles, &present, 3, 1, "a\n", null, &births, &vanish);
39 try std.testing.expectEqual(@as(usize, 0), births.len + vanish.len);
40
41 // An empty list vanishes everything the host had, once every tile has
42 // spent the grace.
43 wall_host.planHostDiff(&tiles, &present, 3, 0, "", null, &births, &vanish);
44 vanish.len = 0;
45 wall_host.planHostDiff(&tiles, &present, 3, 0, "", null, &births, &vanish);
46 try std.testing.expectEqual(@as(usize, 2), vanish.len);
47 try std.testing.expectEqual(@as(usize, 0), births.len);
48 }
49
50 test "planHostDiff: a live pump's tile is not vanished by ONE list that lacks it" {
51 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
52 var tiles = fixture.diffFixture(&shared);
53 var present = [_]bool{ true, true, true };
54
55 var births = BirthNames{};
56 var vanish = TileIdxs{};
57 // A poll can overtake a session's exit: the daemon has already dropped
58 // the name while the pump has not yet read its `exit_status`. Vanishing
59 // on that list loses the exit code — on a wall of one, `endedTile` skips
60 // a tile that is no longer present and mux never leaves.
61 wall_host.planHostDiff(&tiles, &present, 3, 0, "a\n", null, &births, &vanish);
62 try std.testing.expectEqual(@as(usize, 0), vanish.len);
63 try std.testing.expect(tiles[1].missed_once);
64
65 // A list that names it again forgives it: a tile does not accumulate
66 // misses across the seconds it is legitimately live.
67 wall_host.planHostDiff(&tiles, &present, 3, 0, "a\nb\n", null, &births, &vanish);
68 try std.testing.expectEqual(@as(usize, 0), vanish.len);
69 try std.testing.expect(!tiles[1].missed_once);
70
71 // Two consecutive lists without it, and it goes.
72 wall_host.planHostDiff(&tiles, &present, 3, 0, "a\n", null, &births, &vanish);
73 wall_host.planHostDiff(&tiles, &present, 3, 0, "a\n", null, &births, &vanish);
74 try std.testing.expectEqual(@as(usize, 1), vanish.len);
75 try std.testing.expectEqual(@as(usize, 1), vanish.get(0));
76
77 // A pump that has ENDED has no exit code left to lose, so its tile
78 // needs no grace at all.
79 tiles[0].alive.store(false, .release);
80 vanish.len = 0;
81 const only0 = [_]bool{ true, false, false };
82 wall_host.planHostDiff(&tiles, &only0, 3, 0, "", null, &births, &vanish);
83 try std.testing.expectEqual(@as(usize, 1), vanish.len);
84 try std.testing.expectEqual(@as(usize, 0), vanish.get(0));
85 }
86
87 test "planHostDiff: a vanished tile is not present, so the next list does not vanish it twice" {
88 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
89 var tiles = fixture.diffFixture(&shared);
90 var present = [_]bool{ true, false, true };
91
92 var births = BirthNames{};
93 var vanish = TileIdxs{};
94 wall_host.planHostDiff(&tiles, &present, 3, 0, "a\n", null, &births, &vanish);
95 try std.testing.expectEqual(@as(usize, 0), vanish.len);
96 // ...and the name is not reborn either: the tile is gone, but the
97 // daemon no longer lists it, so there is nothing to bring back.
98 try std.testing.expectEqual(@as(usize, 0), births.len);
99 }
100
101 test "planHostDiff: the session this shell is inside is never born as a tile" {
102 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
103 var tiles = fixture.diffFixture(&shared);
104 var present = [_]bool{ true, true, true };
105
106 var births = BirthNames{};
107 var vanish = TileIdxs{};
108 // The daemon has "a", "b" and "self"; "self" is the shell mux runs in,
109 // so a tile of it would paint into the grid it is reading.
110 wall_host.planHostDiff(&tiles, &present, 3, 0, "a\nb\nself\n", "self", &births, &vanish);
111 try std.testing.expectEqual(@as(usize, 0), births.len);
112 try std.testing.expectEqual(@as(usize, 0), vanish.len);
113 }
114
115 test "planHostDiff: a name the wire grammar refuses never becomes a tile, however the peer spells it" {
116 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
117 var tiles = fixture.diffFixture(&shared);
118 var present = [_]bool{ true, true, true };
119
120 var births = BirthNames{};
121 var vanish = TileIdxs{};
122 // A whole reply may be `sessions_text_max`, so ONE "name" in it can be
123 // 1056 bytes; `encodeAttachNamed` memcpys the birth's name into a
124 // 32-byte tail behind nothing but an assert. Peer bytes are filtered
125 // where they become a tile, not asserted about at the wire.
126 const over_long = "x" ** (proto.session_name_max + 1);
127 const list = "a\n" ++ over_long ++ "\nhas space\n\x1b[2J\nc\n";
128 wall_host.planHostDiff(&tiles, &present, 3, 0, list, null, &births, &vanish);
129 try std.testing.expectEqual(@as(usize, 1), births.len);
130 try std.testing.expectEqualStrings("c", births.get(0));
131 }
132
133 test "applyReadyLists: a host added after the wall opened gets its sessions, and reaches past nothing" {
134 var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
135 defer arena.deinit();
136 const alloc = arena.allocator();
137 var shared: Shared = undefined;
138 fixture.stoppedWall(alloc, &shared);
139 var tiles: [8]Tile = undefined;
140 var present = [_]bool{false} ** 8;
141 var live: usize = 0;
142 defer fixture.endPumps(tiles[0..live]);
143 // TWO hosts open the wall and a THIRD arrives from the picker. A fixture
144 // whose table never grows cannot see a per-host flag sized at the count
145 // the wall opened with — which is the whole of this.
146 var table = [_]Host{
147 fixture.testHost(&shared, "box", "/tmp/box.sock"),
148 fixture.testHost(&shared, "vm", "/tmp/vm.sock"),
149 fixture.testHost(&shared, "--sock /tmp/added.sock", "/tmp/added.sock"),
150 };
151 fixture.setList(&table[2], "late\n");
152 table[2].list_ready.store(true, .release);
153
154 _ = wall_host.applyReadyLists(alloc, &tiles, &present, &live, &shared, table[0..3]);
155
156 // The added host's session is a tile, and only the host that reported is
157 // marked: the two that opened the wall are still owed a first list.
158 try std.testing.expect(table[2].applied);
159 try std.testing.expect(!table[0].applied);
160 try std.testing.expect(!table[1].applied);
161 try std.testing.expectEqual(@as(usize, 1), live);
162 try std.testing.expectEqualStrings("late", tiles[0].r.session);
163 }
164
165 test "recordHost: a file it cannot write comes BACK — the wall may be on the alternate screen" {
166 const alloc = std.testing.allocator;
167 var tmp = try TmpDir.make();
168 defer tmp.cleanup();
169 var bad_buf: [256]u8 = undefined;
170 var ok_buf: [256]u8 = undefined;
171 var blocker_buf: [256]u8 = undefined;
172 // A path whose parent is a FILE. `saveBytes` makes missing directories,
173 // so an absent one is not a failure — this is. The caller has to say so
174 // out loud, and WHERE it may say it depends on which caller it is:
175 // stderr before the screen is taken, a notice after.
176 const blocker = try std.fmt.bufPrint(&blocker_buf, "{s}/notadir", .{tmp.path()});
177 try wall.saveBytes(blocker, "x");
178 const bad = try std.fmt.bufPrint(&bad_buf, "{s}/notadir/hosts", .{tmp.path()});
179 const ok = try std.fmt.bufPrint(&ok_buf, "{s}/hosts", .{tmp.path()});
180 try std.testing.expect(wall_host.recordHost(alloc, .{ .sock = "/a" }, "--sock /a", bad) != null);
181 try std.testing.expect(wall_host.recordHost(alloc, .{ .sock = "/a" }, "--sock /a", ok) == null);
182 // `--via` has no form in the host grammar, and writing nothing down for
183 // it is not a failure to report.
184 try std.testing.expect(wall_host.recordHost(alloc, .{ .via = "ssh h muxd proxy" }, "x", bad) == null);
185 }
186
187 test "applyHostList: a host with no live session gets no tile — the wall shows sessions only" {
188 var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
189 defer arena.deinit();
190 const alloc = arena.allocator();
191 var shared: Shared = undefined;
192 fixture.stoppedWall(alloc, &shared);
193 var tiles: [4]Tile = undefined;
194 var present = [_]bool{false} ** 4;
195 var live: usize = 0;
196 defer fixture.endPumps(tiles[0..live]);
197 // The two ways a host has nothing to show, side by side: one that never
198 // answered and one that answered with an empty list.
199 var table = [_]Host{
200 fixture.testHost(&shared, "down", "/tmp/nobody.sock"),
201 fixture.testHost(&shared, "empty", "/tmp/empty.sock"),
202 };
203 table[0].reachable.store(false, .release);
204 fixture.setList(&table[1], "");
205
206 // Twice, because a placeholder that is born once is still a placeholder.
207 for (0..2) |_| {
208 wall_host.applyHostList(alloc, &tiles, &present, &live, &shared, &table, 0);
209 wall_host.applyHostList(alloc, &tiles, &present, &live, &shared, &table, 1);
210 }
211
212 try std.testing.expectEqual(@as(usize, 0), live);
213 try std.testing.expectEqual(@as(usize, 0), wv.presentCount(present[0..]));
214
215 // A session on the empty host, and the wall has exactly the one tile.
216 fixture.setList(&table[1], "a\n");
217 wall_host.applyHostList(alloc, &tiles, &present, &live, &shared, &table, 1);
218
219 try std.testing.expectEqual(@as(usize, 1), wv.presentCount(present[0..live]));
220 try std.testing.expectEqualStrings("a", tiles[0].r.session);
221 try std.testing.expectEqual(@as(?usize, 1), tiles[0].host);
222 }
223
224 test "applyHostList: a live tile survives one list that lost its session, and goes on the next" {
225 var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
226 defer arena.deinit();
227 const alloc = arena.allocator();
228 var shared: Shared = undefined;
229 fixture.stoppedWall(alloc, &shared);
230 var tiles: [4]Tile = undefined;
231 var present = [_]bool{false} ** 4;
232 var live: usize = 0;
233 defer fixture.endPumps(tiles[0..live]);
234 // Two sessions, because the tile that keeps its name is what shows the
235 // grace is per tile and not a wall-wide pause.
236 var table = [_]Host{fixture.testHost(&shared, "box", "/tmp/box.sock")};
237 fixture.setList(&table[0], "a\nb\n");
238 wall_host.applyHostList(alloc, &tiles, &present, &live, &shared, &table, 0);
239 try std.testing.expectEqual(@as(usize, 2), live);
240
241 // The wall's pumps are stopped in this fixture, so the liveness the rule
242 // turns on is stated rather than raced for.
243 tiles[0].alive.store(true, .release);
244 tiles[1].alive.store(true, .release);
245 fixture.setList(&table[0], "a\n");
246 wall_host.applyHostList(alloc, &tiles, &present, &live, &shared, &table, 0);
247 // Still there: this list may have overtaken an `exit_status` the pump
248 // has not read yet, and that code is the run's own on a wall of one.
249 try std.testing.expect(present[1]);
250 try std.testing.expect(present[0]);
251
252 wall_host.applyHostList(alloc, &tiles, &present, &live, &shared, &table, 0);
253 try std.testing.expect(!present[1]);
254 try std.testing.expect(present[0]);
255 }
256
257 test "applyHostList: two sessions on an empty wall are two tiles, the first focused" {
258 var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
259 defer arena.deinit();
260 const alloc = arena.allocator();
261 var shared: Shared = undefined;
262 fixture.stoppedWall(alloc, &shared);
263 var tiles: [4]Tile = undefined;
264 var present = [_]bool{false} ** 4;
265 var live: usize = 0;
266 defer fixture.endPumps(tiles[0..live]);
267 var table = [_]Host{fixture.testHost(&shared, "box", "/tmp/box.sock")};
268 fixture.setList(&table[0], "a\nb\n");
269
270 wall_host.applyHostList(alloc, &tiles, &present, &live, &shared, &table, 0);
271
272 // The first birth has no leaf to sit beside — an empty tree takes it as
273 // its root, and the second inserts against it.
274 try std.testing.expectEqual(@as(usize, 2), live);
275 try std.testing.expectEqual(@as(usize, 2), shared.tree.count());
276 try std.testing.expectEqualStrings("a", tiles[0].r.session);
277 try std.testing.expectEqualStrings("b", tiles[1].r.session);
278 try std.testing.expectEqual(@as(usize, 0), shared.sel);
279 try std.testing.expect(tiles[0].claim_pending.load(.acquire));
280 }
281
282 test "applyHostList: one list's tiles are laid out in the order the daemon reported them" {
283 var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
284 defer arena.deinit();
285 const alloc = arena.allocator();
286 var shared: Shared = undefined;
287 fixture.stoppedWall(alloc, &shared);
288 defer if (shared.last_flat) |*f| f.deinit(alloc);
289 var tiles: [4]Tile = undefined;
290 var present = [_]bool{false} ** 4;
291 var live: usize = 0;
292 defer fixture.endPumps(tiles[0..live]);
293 var table = [_]Host{fixture.testHost(&shared, "box", "/tmp/box.sock")};
294 // THREE, because two cannot fail: the first birth roots the tree and the
295 // second lands after it whatever it anchored on. The third is the one
296 // that has a choice, and anchoring it back at the focus puts it BETWEEN
297 // its two siblings.
298 fixture.setList(&table[0], "a\nb\nc\n");
299
300 wall_host.applyHostList(alloc, &tiles, &present, &live, &shared, &table, 0);
301
302 try std.testing.expectEqual(@as(usize, 3), live);
303 // Down the screen in the daemon's own order. The tile ARRAY is in that
304 // order by construction; what this reads is the flatten, which is where
305 // an anchor that did not move shows up.
306 const flat = shared.last_flat orelse return error.NoFlat;
307 const top_a = (flat.rectOf(0) orelse return error.NoRect).top;
308 const top_b = (flat.rectOf(1) orelse return error.NoRect).top;
309 const top_c = (flat.rectOf(2) orelse return error.NoRect).top;
310 try std.testing.expectEqualStrings("a", tiles[0].r.session);
311 try std.testing.expectEqualStrings("b", tiles[1].r.session);
312 try std.testing.expectEqualStrings("c", tiles[2].r.session);
313 try std.testing.expect(top_a < top_b);
314 try std.testing.expect(top_b < top_c);
315 }
316
317 test "applyHostList: sessions past the wall's capacity are counted in the notice, not dropped in silence" {
318 var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
319 defer arena.deinit();
320 const alloc = arena.allocator();
321 var shared: Shared = undefined;
322 fixture.stoppedWall(alloc, &shared);
323 var tiles: [wv.max_tiles]Tile = undefined;
324 var present = [_]bool{true} ** wv.max_tiles;
325 var live: usize = wv.max_tiles;
326 var names: [wv.max_tiles][2]u8 = undefined;
327 var text: std.ArrayList(u8) = .empty;
328 for (&tiles, 0..) |*t, i| {
329 const name = std.fmt.bufPrint(&names[i], "{d}", .{i}) catch unreachable;
330 t.* = .{
331 .r = .{ .target = .{ .sock = "/tmp/box.sock" }, .label = "box", .session = name },
332 .rect = .{ .top = 0, .left = 0, .rows = 24, .cols = 80 },
333 .shared = &shared,
334 .idx = i,
335 .wake_r = -1,
336 .wake_w = -1,
337 .host = 0,
338 .alive = std.atomic.Value(bool).init(false),
339 };
340 try text.appendSlice(alloc, name);
341 try text.append(alloc, '\n');
342 }
343 // Two more than the wall can hold.
344 try text.appendSlice(alloc, "x\ny\n");
345 var table = [_]Host{fixture.testHost(&shared, "box", "/tmp/box.sock")};
346 fixture.setList(&table[0], text.items);
347
348 wall_host.applyHostList(alloc, &tiles, &present, &live, &shared, &table, 0);
349
350 try std.testing.expectEqual(@as(usize, wv.max_tiles), live);
351 var buf: [96]u8 = undefined;
352 try std.testing.expectEqualStrings("[+2 not shown]", wv.takeNotice(&shared, &buf));
353 }
354
355 test "applyHostList: a wall too thin for a second pane takes what fits and retains nothing for the rest" {
356 // The TESTING allocator, not an arena: what this pins is that a birth
357 // the wall refuses leaves no allocation behind, and only leak detection
358 // can say so. The tile that DID fit owns its copies, so the test frees
359 // exactly those two.
360 const alloc = std.testing.allocator;
361 var shared: Shared = undefined;
362 fixture.stoppedWall(alloc, &shared);
363 // Four rows: one pane fits (`min_session_rows`), two never can — each
364 // would owe a label bar on top of that floor.
365 shared.size = .{ .cols = 80, .rows = 4 };
366 defer shared.tree.deinit();
367 defer if (shared.last_flat) |*f| f.deinit(alloc);
368 defer if (shared.base_flat) |*f| f.deinit(alloc);
369 var tiles: [4]Tile = undefined;
370 var present = [_]bool{false} ** 4;
371 var live: usize = 0;
372 defer fixture.endPumps(tiles[0..live]);
373 defer for (tiles[0..live]) |*t| {
374 alloc.free(t.r.session);
375 alloc.free(t.r.label);
376 };
377 var table = [_]Host{fixture.testHost(&shared, "box", "/tmp/box.sock")};
378 fixture.setList(&table[0], "a\nb\n");
379
380 wall_host.applyHostList(alloc, &tiles, &present, &live, &shared, &table, 0);
381
382 try std.testing.expectEqual(@as(usize, 1), live);
383 try std.testing.expectEqualStrings("a", tiles[0].r.session);
384 var buf: [96]u8 = undefined;
385 try std.testing.expectEqualStrings("[+1 not shown]", wv.takeNotice(&shared, &buf));
386 }
387
388 test "applyHostList: when a host's last session goes the wall empties, and nothing stands in for the host" {
389 var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
390 defer arena.deinit();
391 const alloc = arena.allocator();
392 var shared: Shared = undefined;
393 fixture.stoppedWall(alloc, &shared);
394 var tiles: [4]Tile = undefined;
395 var present = [_]bool{false} ** 4;
396 var live: usize = 1;
397 defer fixture.endPumps(tiles[0..live]);
398 tiles[0] = .{
399 .r = .{ .target = .{ .sock = "/tmp/box.sock" }, .label = "--sock /tmp/box.sock#a", .session = "a" },
400 .rect = .{ .top = 0, .left = 0, .rows = 24, .cols = 80 },
401 .shared = &shared,
402 .idx = 0,
403 .wake_r = -1,
404 .wake_w = -1,
405 .host = 0,
406 .alive = std.atomic.Value(bool).init(false),
407 };
408 present[0] = true;
409 try shared.tree.addFirst(0);
410 var table = [_]Host{fixture.testHost(&shared, "box", "/tmp/box.sock")};
411 // The daemon has nothing left: a restart, or another client ended it.
412 fixture.setList(&table[0], "");
413
414 wall_host.applyHostList(alloc, &tiles, &present, &live, &shared, &table, 0);
415
416 // The host is up with nothing on it, which the wall says by showing
417 // nothing: no placeholder tile takes the gone session's place.
418 try std.testing.expect(!present[0]);
419 try std.testing.expectEqual(@as(usize, 0), wv.presentCount(present[0..]));
420 }
421
422 test "planHostDiff: a creating tile whose attach has not landed yet is not vanished by the list that raced it" {
423 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
424 var tiles = fixture.diffFixture(&shared);
425 // `Ctrl-\ c` picked "b" off the daemon's free list and the tile is
426 // dialling; `pokeHost` asks for the list at exactly this moment, and
427 // the daemon answers before the attach that makes "b" lands.
428 tiles[1].creates = true;
429 var present = [_]bool{ true, true, true };
430
431 var births = BirthNames{};
432 var vanish = TileIdxs{};
433 wall_host.planHostDiff(&tiles, &present, 3, 0, "a\n", null, &births, &vanish);
434 try std.testing.expectEqual(@as(usize, 0), vanish.len);
435
436 // ...and once its session exists, the same list does drop it — after
437 // the one list of grace a live pump gets: a session the daemon HAD and
438 // no longer lists is gone.
439 tiles[1].ever_up.store(true, .release);
440 wall_host.planHostDiff(&tiles, &present, 3, 0, "a\n", null, &births, &vanish);
441 wall_host.planHostDiff(&tiles, &present, 3, 0, "a\n", null, &births, &vanish);
442 try std.testing.expectEqual(@as(usize, 1), vanish.len);
443 try std.testing.expectEqual(@as(usize, 1), vanish.get(0));
444 }
445
446 test "hostsOverCapacity: a hosts file past the wall's room is counted out loud, not cut in silence" {
447 var buf: [64]u8 = undefined;
448 // Exactly full is not over: the boundary is where a silent cut starts.
449 try std.testing.expectEqual(@as(?[]const u8, null), wall_host.hostsOverCapacity(&buf, wv.max_tiles));
450 try std.testing.expectEqual(@as(?[]const u8, null), wall_host.hostsOverCapacity(&buf, 0));
451 try std.testing.expectEqualStrings(
452 "[+1 host in the file not shown]",
453 wall_host.hostsOverCapacity(&buf, wv.max_tiles + 1) orelse "",
454 );
455 try std.testing.expectEqualStrings(
456 "[+9 hosts in the file not shown]",
457 wall_host.hostsOverCapacity(&buf, wv.max_tiles + 9) orelse "",
458 );
459 }
460
461 test "addHost: a forgotten host's slot comes back, so a and x cannot fill the table between them" {
462 const alloc = std.testing.allocator;
463 var tmp = try TmpDir.make();
464 defer tmp.cleanup();
465 var path_buf: [256]u8 = undefined;
466 const path = try std.fmt.bufPrint(&path_buf, "{s}/hosts", .{tmp.path()});
467 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
468 defer shared.tree.deinit();
469
470 // A FULL table is the only one that can see a forgotten slot refused:
471 // `hosts_live` only ever grew, so 32 adds spent the wall for good even
472 // with two rows left in the picker.
473 var table: [2]Host = undefined;
474 table[0] = .{ .spec = .{ .spelling = "--sock /a", .target = .{ .sock = "/a" }, .poll_target = .{ .sock = "/a" } }, .shared = &shared };
475 table[1] = .{ .spec = .{ .spelling = "--sock /b", .target = .{ .sock = "/b" }, .poll_target = .{ .sock = "/b" } }, .shared = &shared };
476 var n: usize = 2;
477 var buf: [96]u8 = undefined;
478 try std.testing.expect(wall_host.addHost(alloc, &shared, &table, &n, "--sock /c", null, 30_000, path) == .refused);
479 try std.testing.expectEqualStrings("[no room on the wall for another host]", wv.takeNotice(&shared, &buf));
480
481 // `x` asks the poller to leave; until it has, the slot is still its own.
482 // A spec overwritten under a live poller is a thread dialling a target
483 // that has been replaced.
484 table[0].forgotten.store(true, .release);
485 try std.testing.expect(wall_host.addHost(alloc, &shared, &table, &n, "--sock /c", null, 30_000, path) == .refused);
486 try std.testing.expectEqualStrings("[no room on the wall for another host]", wv.takeNotice(&shared, &buf));
487
488 // The poller's last act, and the slot is the wall's again.
489 table[0].poller_done.store(true, .release);
490 const at = switch (wall_host.addHost(alloc, &shared, &table, &n, "--sock /c", null, 30_000, path)) {
491 .added => |i| i,
492 else => return error.HostWasNotAdded,
493 };
494 // `run`'s allocator is an ARENA that never returns, so a reused slot's
495 // old spec is deliberately not freed there; a test outlives its own.
496 defer alloc.free(table[at].spec.spelling);
497 try std.testing.expectEqual(@as(usize, 0), at);
498 try std.testing.expectEqual(@as(usize, 2), n);
499 try std.testing.expectEqualStrings("/c", table[0].spec.target.sock);
500 try std.testing.expect(!table[0].forgotten.load(.acquire));
501 try std.testing.expect(!table[0].poller_done.load(.acquire));
502 }
503
504 test "addHost: the prompt adds a DAEMON — a flag, a session and a host already listed are all refused" {
505 const alloc = std.testing.allocator;
506 var tmp = try TmpDir.make();
507 defer tmp.cleanup();
508 var path_buf: [256]u8 = undefined;
509 const path = try std.fmt.bufPrint(&path_buf, "{s}/hosts", .{tmp.path()});
510 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
511 defer shared.tree.deinit();
512 // Two hosts on the wall before the prompt is ever opened: a table that
513 // only ever held one cannot see an append landing on the wrong row.
514 var table: [4]Host = undefined;
515 table[0] = .{ .spec = .{ .spelling = "--sock /a", .target = .{ .sock = "/a" }, .poll_target = .{ .sock = "/a" } }, .shared = &shared };
516 table[1] = .{ .spec = .{ .spelling = "--sock /b", .target = .{ .sock = "/b" }, .poll_target = .{ .sock = "/b" } }, .shared = &shared };
517 var n: usize = 2;
518 var buf: [96]u8 = undefined;
519
520 // The typo `mux hosts add -A host` dies at, typed at the prompt
521 // instead: a host named `-A` is nobody's intent at either mouth.
522 try std.testing.expect(wall_host.addHost(alloc, &shared, &table, &n, "-A nosuchhost.invalid", null, 30_000, path) == .refused);
523 try std.testing.expectEqual(@as(usize, 2), n);
524 try std.testing.expectEqualStrings("[bad host: FlagLikeTarget]", wv.takeNotice(&shared, &buf));
525
526 // `#` is the old wall grammar's session split. The prompt names a
527 // daemon now, and the refusal says which half was one too many.
528 try std.testing.expect(wall_host.addHost(alloc, &shared, &table, &n, "--sock /c#work", null, 30_000, path) == .refused);
529 try std.testing.expectEqual(@as(usize, 2), n);
530 try std.testing.expect(std.mem.indexOf(u8, wv.takeNotice(&shared, &buf), "daemons") != null);
531
532 // A real one: it lands in the table AND in the file, because the wall
533 // the user is looking at and the wall they get back are the same wall.
534 const at = switch (wall_host.addHost(alloc, &shared, &table, &n, "--sock /c", null, 30_000, path)) {
535 .added => |i| i,
536 else => return error.HostWasNotAdded,
537 };
538 // `run`'s allocator never returns, so the table's copy is deliberately
539 // never freed there; a test outlives its allocator's bookkeeping.
540 defer alloc.free(table[at].spec.spelling);
541 try std.testing.expectEqual(@as(usize, 2), at);
542 try std.testing.expectEqual(@as(usize, 3), n);
543 try std.testing.expectEqualStrings("/c", table[2].spec.target.sock);
544 var h = try hosts.load(alloc, path);
545 defer h.deinit(alloc);
546 try std.testing.expect(h.has("--sock /c"));
547
548 // Twice is once: the poller is already asking that daemon for its
549 // sessions, and a second one would tile every one of them again. It
550 // says so and names the row, because an unchanged popup and an unmoved
551 // selection is the prompt answering a real host with nothing at all.
552 try std.testing.expectEqual(
553 AddHost{ .listed = 2 },
554 wall_host.addHost(alloc, &shared, &table, &n, "--sock /c", null, 30_000, path),
555 );
556 try std.testing.expectEqual(@as(usize, 3), n);
557 try std.testing.expectEqualStrings("[that host is already on the wall]", wv.takeNotice(&shared, &buf));
558
559 // Full is said out loud, not swallowed.
560 n = table.len;
561 try std.testing.expect(wall_host.addHost(alloc, &shared, &table, &n, "--sock /d", null, 30_000, path) == .refused);
562 try std.testing.expectEqualStrings("[no room on the wall for another host]", wv.takeNotice(&shared, &buf));
563 }
564
565 test "resolveHost refuses a sun_path-overflowing sock path" {
566 const alloc = std.testing.allocator;
567 // Refused at usage altitude, not at a connect that fails with a
568 // truncated sun_path nobody typed.
569 const long = "--sock /" ++ "x" ** 200;
570 try std.testing.expectError(error.SockPathTooLong, wall_host.resolveHost(alloc, long, null, 30_000));
571 }
572
573 test "resolveHost: an ssh host is polled by a recipe that cannot prompt and cannot narrate" {
574 var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
575 defer arena.deinit();
576 const alloc = arena.allocator();
577
578 // Two spellings, because the poll target is built per host and a shared
579 // one would point every stripe at the first host's ssh line.
580 for ([_][]const u8{ "box", "user@gate" }) |spelling| {
581 const spec = try wall_host.resolveHost(alloc, spelling, null, 30_000);
582 try std.testing.expect(std.mem.indexOf(u8, spec.poll_target.hand.ssh_cmd, "BatchMode=yes") != null);
583 try std.testing.expectEqualStrings(spelling, spec.poll_target.hand.host);
584 // The attach the user sees must still be able to ask for a password.
585 try std.testing.expect(std.mem.indexOf(u8, spec.target.hand.ssh_cmd, "BatchMode") == null);
586 // Neither door narrates a fallback: both are dialled under a wall
587 // that owns the alternate screen.
588 try std.testing.expect(!spec.target.hand.asked);
589 try std.testing.expect(!spec.poll_target.hand.asked);
590 }
591
592 // A transport that cannot prompt has one target, not a copy of one.
593 const s = try wall_host.resolveHost(alloc, "--sock /tmp/a.sock", null, 30_000);
594 try std.testing.expectEqualStrings(s.target.sock, s.poll_target.sock);
595 }
596
597 test "pollDelayMs: a poll that cost an ssh login is asked ten times less often" {
598 // The whole point of the number: a pipe-answered poll spawned `ssh`,
599 // read the announce and killed it — a remote auth log line per cycle.
600 try std.testing.expectEqual(@as(u64, 10_000), wall_host.pollDelayMs(.pipe));
601 try std.testing.expectEqual(@as(u64, 1_000), wall_host.pollDelayMs(.fd));
602 try std.testing.expectEqual(@as(u64, 1_000), wall_host.pollDelayMs(.quic));
603 }
604
605 test "otherHosts: the dialled host is not tiled twice, and the rest follow in file order" {
606 const alloc = std.testing.allocator;
607 var tmp = try TmpDir.make();
608 defer tmp.cleanup();
609 var buf: [256]u8 = undefined;
610 const path = try std.fmt.bufPrint(&buf, "{s}/hosts", .{tmp.path()});
611 // Off-origin: the host the user dialled is the file's SECOND line, so a
612 // skip that only ever worked on line 0 is caught here.
613 try wall.saveBytes(path, "--sock /a\n--sock /b\n");
614
615 var arena_state = std.heap.ArenaAllocator.init(alloc);
616 defer arena_state.deinit();
617 const arena = arena_state.allocator();
618 var specs: std.ArrayList(HostSpec) = .empty;
619 try specs.append(arena, .{ .spelling = "--sock /b", .target = .{ .sock = "/b" }, .poll_target = .{ .sock = "/b" } });
620 wall_host.otherHosts(arena, &specs, "--sock /b", path, null, 0);
621
622 try std.testing.expectEqual(@as(usize, 2), specs.items.len);
623 try std.testing.expectEqualStrings("--sock /b", specs.items[0].spelling);
624 try std.testing.expectEqualStrings("--sock /a", specs.items[1].spelling);
625
626 // A file the grammar refuses costs the user the WALL, never the session
627 // they asked for: the entry host stands alone and the attach goes on.
628 try wall.saveBytes(path, "--sock /a\nbox#old\n");
629 var one: std.ArrayList(HostSpec) = .empty;
630 try one.append(arena, .{ .spelling = "--sock /b", .target = .{ .sock = "/b" }, .poll_target = .{ .sock = "/b" } });
631 wall_host.otherHosts(arena, &one, "--sock /b", path, null, 0);
632 try std.testing.expectEqual(@as(usize, 1), one.items.len);
633 }
src/tui/wall_test_layout.zig
Old New
@@ -0,0 +1,916 @@
1 //! The pane tree, the rects it cuts and the layout sidecar (wall_layout.zig).
2 const std = @import("std");
3 const proto = @import("protocol");
4 const wall = @import("wall");
5 const layout = @import("layout");
6 const TmpDir = @import("testtmp").TmpDir;
7 const fixture = @import("wall_test_harness.zig");
8 const wall_host = @import("wall_host.zig");
9 const wall_layout = @import("wall_layout.zig");
10 const wv = @import("wallview.zig");
11 const ResizeWitness = fixture.ResizeWitness;
12 const Resolved = wall_host.Resolved;
13 const Shared = wv.Shared;
14 const Tile = wv.Tile;
15 const WallScreen = fixture.WallScreen;
16
17 test "the tree's stacked cut splits rows with the remainder at the top" {
18 const alloc = std.testing.allocator;
19 var tree = layout.Tree.init(alloc);
20 defer tree.deinit();
21 try tree.addFirst(0);
22 try tree.insert(0, 1);
23 try tree.insert(1, 2);
24 var f = try tree.flatten(alloc, 25, 80, wall_layout.wallFloors(3), null);
25 defer f.deinit(alloc);
26 try std.testing.expectEqual(layout.Rect{ .top = 0, .left = 0, .rows = 9, .cols = 80 }, f.rectOf(0).?);
27 try std.testing.expectEqual(layout.Rect{ .top = 9, .left = 0, .rows = 8, .cols = 80 }, f.rectOf(1).?);
28 try std.testing.expectEqual(layout.Rect{ .top = 17, .left = 0, .rows = 8, .cols = 80 }, f.rectOf(2).?);
29 }
30
31 test "the tree refuses a wall that cannot show a content row" {
32 const alloc = std.testing.allocator;
33 var tree = layout.Tree.init(alloc);
34 defer tree.deinit();
35 // 12 tiles over 23 rows: 1 row each — a label with no content.
36 try tree.addFirst(0);
37 for (1..12) |i| try tree.insert(@intCast(i - 1), @intCast(i));
38 try std.testing.expectError(error.TooSmall, tree.flatten(alloc, 23, 80, wall_layout.wallFloors(12), null));
39 }
40
41 test "the tree refuses a multi-tile cut too thin for the daemon's row floor" {
42 const alloc = std.testing.allocator;
43 var tree = layout.Tree.init(alloc);
44 defer tree.deinit();
45 try tree.addFirst(0);
46 try tree.insert(0, 1);
47 // Two tiles on five rows: each rect is two rows, and under a label
48 // bar that is one content row — below `min_session_rows`, so the
49 // daemon refuses the resize and the tile freezes on a stale grid.
50 try std.testing.expectError(error.TooSmall, tree.flatten(alloc, 5, 80, wall_layout.wallFloors(2), null));
51 try std.testing.expectError(error.TooSmall, tree.flatten(alloc, 4, 80, wall_layout.wallFloors(2), null));
52 // Six rows is the first cut that fits: three a rect, two of them
53 // content under the bar — exactly the daemon's row floor.
54 var f = try tree.flatten(alloc, 6, 80, wall_layout.wallFloors(2), null);
55 defer f.deinit(alloc);
56 try std.testing.expectEqual(@as(u16, 3), f.rectOf(0).?.rows);
57 try std.testing.expectEqual(@as(u16, 3), f.rectOf(1).?.rows);
58 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 6 }, .is_tty = false };
59 shared.label_rows = 1; // two tiles draw a bar
60 var t0 = Tile{
61 .r = .{ .target = .{ .sock = "/tmp/x" }, .label = "x", .session = "" },
62 .rect = f.rectOf(0).?,
63 .shared = &shared,
64 .idx = 0,
65 .wake_r = -1,
66 .wake_w = -1,
67 };
68 var t1 = Tile{
69 .r = .{ .target = .{ .sock = "/tmp/y" }, .label = "y", .session = "" },
70 .rect = f.rectOf(1).?,
71 .shared = &shared,
72 .idx = 1,
73 .wake_r = -1,
74 .wake_w = -1,
75 };
76 try std.testing.expectEqual(proto.min_session_rows, t0.viewRows());
77 try std.testing.expectEqual(proto.min_session_rows, t1.viewRows());
78 // A one-tile wall draws no bar, so its floor is the daemon's alone.
79 var one = layout.Tree.init(alloc);
80 defer one.deinit();
81 try one.addFirst(0);
82 var of = try one.flatten(alloc, 2, 80, wall_layout.wallFloors(1), null);
83 defer of.deinit(alloc);
84 try std.testing.expectEqual(@as(u16, 2), of.rectOf(0).?.rows);
85 }
86
87 test "viewRows: a one-tile wall keeps every row, a multi-tile wall loses the label bar" {
88 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
89 // A one-tile wall: no label bar, the tile claims every row.
90 shared.label_rows = 0;
91 var t0 = Tile{
92 .r = .{ .target = .{ .sock = "/tmp/x" }, .label = "x", .session = "" },
93 .rect = .{ .top = 0, .left = 0, .rows = 24, .cols = 80 },
94 .shared = &shared,
95 .idx = 0,
96 .wake_r = -1,
97 .wake_w = -1,
98 };
99 try std.testing.expectEqual(@as(u16, 24), t0.viewRows());
100
101 // A two-tile wall: each tile loses one row to the label bar.
102 shared.label_rows = 1;
103 var t1 = Tile{
104 .r = .{ .target = .{ .sock = "/tmp/x" }, .label = "x", .session = "" },
105 .rect = .{ .top = 0, .left = 0, .rows = 12, .cols = 80 },
106 .shared = &shared,
107 .idx = 0,
108 .wake_r = -1,
109 .wake_w = -1,
110 };
111 var t2 = Tile{
112 .r = .{ .target = .{ .sock = "/tmp/y" }, .label = "y", .session = "" },
113 .rect = .{ .top = 12, .left = 0, .rows = 12, .cols = 80 },
114 .shared = &shared,
115 .idx = 1,
116 .wake_r = -1,
117 .wake_w = -1,
118 };
119 try std.testing.expectEqual(@as(u16, 11), t1.viewRows());
120 try std.testing.expectEqual(@as(u16, 11), t2.viewRows());
121 }
122
123 test "relayout sets resize_pending on every live tile" {
124 const alloc = std.testing.allocator;
125 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
126 shared.tree = layout.Tree.init(alloc);
127 shared.flat_alloc = alloc;
128 defer shared.tree.deinit();
129 defer if (shared.last_flat) |*f| f.deinit(alloc);
130 defer if (shared.base_flat) |*f| f.deinit(alloc);
131 try shared.tree.addFirst(0);
132 try shared.tree.insert(0, 1);
133 const tiles = try alloc.alloc(Tile, 2);
134 defer alloc.free(tiles);
135 const present = try alloc.alloc(bool, 2);
136 defer alloc.free(present);
137 present[0] = true;
138 present[1] = true;
139 for (tiles, 0..) |*t, i| {
140 t.* = Tile{
141 .r = .{ .target = .{ .sock = "/tmp/x" }, .label = "x", .session = "" },
142 .rect = .{ .top = 0, .left = 0, .rows = 24, .cols = 80 },
143 .shared = &shared,
144 .idx = i,
145 .wake_r = -1,
146 .wake_w = -1,
147 };
148 }
149 wall_layout.relayout(alloc, tiles, present, &shared, 0);
150 // Two tiles: a label bar appears.
151 try std.testing.expectEqual(@as(u8, 1), shared.label_rows);
152 // Every tile is doorbelled: its pump sends the new rect.
153 try std.testing.expect(tiles[0].resize_pending);
154 try std.testing.expect(tiles[1].resize_pending);
155 }
156
157 test "a relayout landing mid-pass is still sent" {
158 const alloc = std.testing.allocator;
159 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
160 shared.tree = layout.Tree.init(alloc);
161 shared.flat_alloc = alloc;
162 defer shared.tree.deinit();
163 defer if (shared.last_flat) |*f| f.deinit(alloc);
164 defer if (shared.base_flat) |*f| f.deinit(alloc);
165 try shared.tree.addFirst(0);
166 try shared.tree.insert(0, 1);
167 const tiles = try alloc.alloc(Tile, 2);
168 defer alloc.free(tiles);
169 const present = [_]bool{ true, true };
170 for (tiles, 0..) |*t, i| {
171 t.* = Tile{
172 .r = .{ .target = .{ .sock = "/tmp/x" }, .label = "x", .session = "" },
173 .rect = .{ .top = 0, .left = 0, .rows = 24, .cols = 80 },
174 .shared = &shared,
175 .idx = i,
176 .wake_r = -1,
177 .wake_w = -1,
178 };
179 }
180
181 var w = ResizeWitness{ .t = &tiles[0] };
182 const th = try std.Thread.spawn(.{}, ResizeWitness.run, .{&w});
183 defer {
184 w.stop.store(true, .release);
185 th.join();
186 }
187 // The dimension this fixture varies is TIME: the wall is re-cut while a
188 // pump is mid-pass, over and over, so a relayout is free to land at any
189 // point inside one. Both heights clear `wallFloors(2)`, so every cut
190 // really does move the rect.
191 const heights = [_]u16{ 24, 22, 24, 23 };
192 var i: usize = 0;
193 while (i < 400) : (i += 1) {
194 shared.paint_mu.lock();
195 shared.size = .{ .cols = 80, .rows = heights[i % heights.len] };
196 shared.paint_mu.unlock();
197 wall_layout.relayout(alloc, tiles, &present, &shared, 0);
198
199 // Quiesce: the pump owes nothing, and the pass that took the last
200 // doorbell has finished. Only then is "what the daemon holds" a
201 // settled question.
202 while (true) {
203 shared.paint_mu.lock();
204 const owed = tiles[0].resize_pending;
205 shared.paint_mu.unlock();
206 if (!owed) break;
207 }
208 const n = w.passes.load(.acquire);
209 while (w.passes.load(.acquire) < n + 1) {}
210
211 shared.paint_mu.lock();
212 const want = ResizeWitness.pack(tiles[0].rect.cols, tiles[0].rect.rows -| shared.label_rows);
213 shared.paint_mu.unlock();
214 // The claim: a quiet wall and a quiet pump agree on the grid. A
215 // doorbell consumed apart from the rect it describes breaks this —
216 // the pump sent the rect it snapshotted BEFORE the re-cut, cleared
217 // the flag, and nothing re-sends.
218 try std.testing.expectEqual(want, w.sent.load(.acquire));
219 }
220 }
221
222 test "a terminal too small for the cut falls back to the focused pane, and grows back" {
223 const alloc = std.testing.allocator;
224 const n = 7;
225 var screen = try WallScreen.init(alloc, 80, 24);
226 defer screen.deinit();
227 var shared = Shared{ .out_fd = screen.w, .size = .{ .cols = 80, .rows = 24 }, .is_tty = true };
228 shared.tree = layout.Tree.init(alloc);
229 shared.flat_alloc = alloc;
230 defer shared.tree.deinit();
231 defer if (shared.last_flat) |*f| f.deinit(alloc);
232 defer if (shared.base_flat) |*f| f.deinit(alloc);
233 try shared.tree.addFirst(0);
234 var k: u8 = 1;
235 while (k < n) : (k += 1) try shared.tree.insert(k - 1, k);
236 const tiles = try alloc.alloc(Tile, n);
237 defer alloc.free(tiles);
238 const present = try alloc.alloc(bool, n);
239 defer alloc.free(present);
240 var labels: [n][2]u8 = undefined;
241 for (tiles, 0..) |*t, i| {
242 present[i] = true;
243 labels[i] = .{ 't', '0' + @as(u8, @intCast(i)) };
244 t.* = Tile{
245 .r = .{ .target = .{ .sock = "/tmp/x" }, .label = &labels[i], .session = "" },
246 .rect = .{ .top = 0, .left = 0, .rows = 24, .cols = 80 },
247 .shared = &shared,
248 .idx = i,
249 .wake_r = -1,
250 .wake_w = -1,
251 };
252 }
253 // One tile whose pump has ended: the keyboard is the only thread left
254 // that can paint its bar, so it is the tile that proves a HIDDEN pane
255 // gets no bar either.
256 tiles[n - 1].alive.store(false, .release);
257
258 // The dimension this fixture varies is the terminal's height — the one
259 // the wall is cut against. 24 rows hold seven panes under their bars;
260 // 20 do not, and `flatten` says so; and the wall has to come back.
261 for ([_]u16{ 24, 20, 24 }) |rows| {
262 try screen.eng.resize(80, rows);
263 shared.paint_mu.lock();
264 shared.size = .{ .cols = 80, .rows = rows };
265 shared.paint_mu.unlock();
266 wall_layout.relayout(alloc, tiles, present, &shared, 0);
267 // What the pumps do with the generation bump: repaint their bars.
268 for (tiles, present) |*t, p| if (p) wv.paintLabel(t, .up);
269 screen.drain();
270 const dump = try screen.eng.dumpPlain(alloc);
271 defer alloc.free(dump);
272
273 // No tile ever claims a row the terminal does not have. A SIGWINCH
274 // is not an operation and cannot be refused: doing nothing leaves
275 // every rect pointing past the bottom of the screen.
276 for (tiles, present) |*t, p| {
277 if (!p) continue;
278 try std.testing.expect(t.rect.top + t.rect.rows <= rows);
279 try std.testing.expect(t.rect.left + t.rect.cols <= 80);
280 }
281 if (rows == 20) {
282 // The degrade: the focused pane whole, every other pane at 0x0
283 // — a rect that paints nothing and claims no size — and the
284 // tree untouched underneath.
285 try std.testing.expectEqual(@as(u16, 0), shared.label_rows);
286 try std.testing.expectEqual(@as(u16, 20), tiles[0].rect.rows);
287 try std.testing.expectEqual(@as(u16, 80), tiles[0].rect.cols);
288 try std.testing.expectEqual(@as(u16, 0), tiles[0].rect.top);
289 for (tiles[1..]) |*t| try std.testing.expectEqual(@as(u16, 0), t.rect.rows);
290 // One pane, so no bars: every row of the grid the wall itself
291 // wrote is blank, hidden tiles included.
292 var it = std.mem.splitScalar(u8, dump, '\n');
293 while (it.next()) |l|
294 try std.testing.expectEqual(@as(usize, 0), std.mem.trim(u8, l, " ").len);
295 } else {
296 try std.testing.expectEqual(@as(u16, 1), shared.label_rows);
297 // Seven bars, each on the first row of the pane it names.
298 for (tiles) |*t| {
299 const l = WallScreen.line(dump, t.rect.top) orelse return error.NoSuchRow;
300 try std.testing.expect(std.mem.indexOf(u8, l, t.r.label) != null);
301 }
302 }
303 }
304 }
305
306 test "fullscreen gives the focused tile the whole terminal and hides the rest" {
307 const alloc = std.testing.allocator;
308 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
309 shared.tree = layout.Tree.init(alloc);
310 shared.flat_alloc = alloc;
311 defer shared.tree.deinit();
312 defer if (shared.last_flat) |*f| f.deinit(alloc);
313 defer if (shared.base_flat) |*f| f.deinit(alloc);
314 try shared.tree.addFirst(0);
315 try shared.tree.insert(0, 1);
316 const tiles = try alloc.alloc(Tile, 2);
317 defer alloc.free(tiles);
318 const present = [_]bool{ true, true };
319 for (tiles, 0..) |*t, i| {
320 t.* = Tile{
321 .r = .{ .target = .{ .sock = "/tmp/x" }, .label = "x", .session = "" },
322 .rect = .{ .top = 0, .left = 0, .rows = 24, .cols = 80 },
323 .shared = &shared,
324 .idx = i,
325 .wake_r = -1,
326 .wake_w = -1,
327 };
328 }
329 shared.fullscreen = true;
330 wall_layout.relayout(alloc, tiles, &present, &shared, 0);
331 // No label bar: the fullscreened pane is a one-tile wall.
332 try std.testing.expectEqual(@as(u8, 0), shared.label_rows);
333 // Tile 0 gets the whole terminal; tile 1 gets nothing.
334 try std.testing.expectEqual(@as(u16, 24), tiles[0].rect.rows);
335 try std.testing.expectEqual(@as(u16, 80), tiles[0].rect.cols);
336 try std.testing.expectEqual(@as(u16, 0), tiles[1].rect.rows);
337 try std.testing.expectEqual(@as(u16, 0), tiles[1].rect.cols);
338 // The base flat is kept for neighbor.
339 try std.testing.expect(shared.base_flat != null);
340 }
341
342 test "toggle off fullscreen restores the real rects" {
343 const alloc = std.testing.allocator;
344 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
345 shared.tree = layout.Tree.init(alloc);
346 shared.flat_alloc = alloc;
347 defer shared.tree.deinit();
348 defer if (shared.last_flat) |*f| f.deinit(alloc);
349 defer if (shared.base_flat) |*f| f.deinit(alloc);
350 try shared.tree.addFirst(0);
351 try shared.tree.insert(0, 1);
352 const tiles = try alloc.alloc(Tile, 2);
353 defer alloc.free(tiles);
354 const present = [_]bool{ true, true };
355 for (tiles, 0..) |*t, i| {
356 t.* = Tile{
357 .r = .{ .target = .{ .sock = "/tmp/x" }, .label = "x", .session = "" },
358 .rect = .{ .top = 0, .left = 0, .rows = 24, .cols = 80 },
359 .shared = &shared,
360 .idx = i,
361 .wake_r = -1,
362 .wake_w = -1,
363 };
364 }
365 shared.fullscreen = true;
366 wall_layout.relayout(alloc, tiles, &present, &shared, 0);
367 shared.fullscreen = false;
368 wall_layout.relayout(alloc, tiles, &present, &shared, 0);
369 // Two tiles again: label bar back, both have real rects (stacked:
370 // full width, half height each).
371 try std.testing.expectEqual(@as(u8, 1), shared.label_rows);
372 try std.testing.expectEqual(@as(u16, 80), tiles[0].rect.cols);
373 try std.testing.expectEqual(@as(u16, 80), tiles[1].rect.cols);
374 try std.testing.expect(tiles[0].rect.rows < 24);
375 try std.testing.expect(tiles[1].rect.rows > 0);
376 }
377
378 test "focus_dir while fullscreened follows the focus" {
379 const alloc = std.testing.allocator;
380 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
381 shared.tree = layout.Tree.init(alloc);
382 shared.flat_alloc = alloc;
383 defer shared.tree.deinit();
384 defer if (shared.last_flat) |*f| f.deinit(alloc);
385 defer if (shared.base_flat) |*f| f.deinit(alloc);
386 try shared.tree.addFirst(0);
387 try shared.tree.insert(0, 1);
388 const tiles = try alloc.alloc(Tile, 2);
389 defer alloc.free(tiles);
390 const present = [_]bool{ true, true };
391 for (tiles, 0..) |*t, i| {
392 t.* = Tile{
393 .r = .{ .target = .{ .sock = "/tmp/x" }, .label = "x", .session = "" },
394 .rect = .{ .top = 0, .left = 0, .rows = 24, .cols = 80 },
395 .shared = &shared,
396 .idx = i,
397 .wake_r = -1,
398 .wake_w = -1,
399 };
400 }
401 shared.fullscreen = true;
402 wall_layout.relayout(alloc, tiles, &present, &shared, 0);
403 // Move focus to tile 1 while fullscreened.
404 wv.focusAnswer(alloc, tiles, &present, &shared, false, 1);
405 // The full rect followed: tile 1 now owns the terminal.
406 try std.testing.expectEqual(@as(u16, 24), tiles[1].rect.rows);
407 try std.testing.expectEqual(@as(u16, 80), tiles[1].rect.cols);
408 try std.testing.expectEqual(@as(u16, 0), tiles[0].rect.rows);
409 try std.testing.expectEqual(@as(usize, 1), shared.sel);
410 }
411
412 test "resize: l grows the focused pane, h shrinks it" {
413 // Spec: h shrinks width, l grows width. layout.resize always makes
414 // the focus GAIN cells, so shrink = grow a neighbor at focus's
415 // expense. Two beside panes at 80x24 (floors {3,2}): equal split is
416 // 39/40 (rail takes 1). l moves the rail right; h moves it left.
417 const alloc = std.testing.allocator;
418 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
419 shared.tree = layout.Tree.init(alloc);
420 shared.flat_alloc = alloc;
421 defer shared.tree.deinit();
422 defer if (shared.last_flat) |*f| f.deinit(alloc);
423 defer if (shared.base_flat) |*f| f.deinit(alloc);
424 try shared.tree.addFirst(0);
425 try shared.tree.splitRight(0, 1);
426 const tiles = try alloc.alloc(Tile, 2);
427 defer alloc.free(tiles);
428 const present = [_]bool{ true, true };
429 for (tiles, 0..) |*t, i| {
430 t.* = Tile{
431 .r = .{ .target = .{ .sock = "/tmp/x" }, .label = "x", .session = "" },
432 .rect = .{ .top = 0, .left = 0, .rows = 24, .cols = 80 },
433 .shared = &shared,
434 .idx = i,
435 .wake_r = -1,
436 .wake_w = -1,
437 };
438 }
439 wall_layout.relayout(alloc, tiles, &present, &shared, 0);
440 const before = tiles[0].rect.cols;
441 // l (grow width): focus 0 gains from its right sibling.
442 try std.testing.expect(wall_layout.doResize(alloc, tiles, &present, &shared, 0, .right));
443 try std.testing.expect(tiles[0].rect.cols > before);
444 // h (shrink width): the right neighbor gains a cell back from focus.
445 try std.testing.expect(wall_layout.doResize(alloc, tiles, &present, &shared, 0, .left));
446 try std.testing.expectEqual(before, tiles[0].rect.cols);
447 }
448
449 test "resize: j grows height, k shrinks height" {
450 const alloc = std.testing.allocator;
451 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
452 shared.tree = layout.Tree.init(alloc);
453 shared.flat_alloc = alloc;
454 defer shared.tree.deinit();
455 defer if (shared.last_flat) |*f| f.deinit(alloc);
456 defer if (shared.base_flat) |*f| f.deinit(alloc);
457 try shared.tree.addFirst(0);
458 try shared.tree.splitBelow(0, 1);
459 const tiles = try alloc.alloc(Tile, 2);
460 defer alloc.free(tiles);
461 const present = [_]bool{ true, true };
462 for (tiles, 0..) |*t, i| {
463 t.* = Tile{
464 .r = .{ .target = .{ .sock = "/tmp/x" }, .label = "x", .session = "" },
465 .rect = .{ .top = 0, .left = 0, .rows = 24, .cols = 80 },
466 .shared = &shared,
467 .idx = i,
468 .wake_r = -1,
469 .wake_w = -1,
470 };
471 }
472 wall_layout.relayout(alloc, tiles, &present, &shared, 0);
473 const before = tiles[0].rect.rows;
474 try std.testing.expect(wall_layout.doResize(alloc, tiles, &present, &shared, 0, .down));
475 try std.testing.expect(tiles[0].rect.rows > before);
476 try std.testing.expect(wall_layout.doResize(alloc, tiles, &present, &shared, 0, .up));
477 try std.testing.expectEqual(before, tiles[0].rect.rows);
478 }
479
480 test "resize refuses while fullscreened" {
481 const alloc = std.testing.allocator;
482 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
483 shared.tree = layout.Tree.init(alloc);
484 shared.flat_alloc = alloc;
485 defer shared.tree.deinit();
486 defer if (shared.last_flat) |*f| f.deinit(alloc);
487 defer if (shared.base_flat) |*f| f.deinit(alloc);
488 try shared.tree.addFirst(0);
489 try shared.tree.splitRight(0, 1);
490 const tiles = try alloc.alloc(Tile, 2);
491 defer alloc.free(tiles);
492 const present = [_]bool{ true, true };
493 for (tiles, 0..) |*t, i| {
494 t.* = Tile{
495 .r = .{ .target = .{ .sock = "/tmp/x" }, .label = "x", .session = "" },
496 .rect = .{ .top = 0, .left = 0, .rows = 24, .cols = 80 },
497 .shared = &shared,
498 .idx = i,
499 .wake_r = -1,
500 .wake_w = -1,
501 };
502 }
503 shared.fullscreen = true;
504 wall_layout.relayout(alloc, tiles, &present, &shared, 0);
505 try std.testing.expect(!wall_layout.doResize(alloc, tiles, &present, &shared, 0, .right));
506 }
507
508 test "a pump's answer that grew the wall re-cuts it; a mere focus move does not" {
509 const alloc = std.testing.allocator;
510 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
511 defer if (shared.last_flat) |*f| f.deinit(shared.flat_alloc);
512 defer if (shared.base_flat) |*f| f.deinit(shared.flat_alloc);
513 const tiles = try alloc.alloc(Tile, 2);
514 defer alloc.free(tiles);
515 const present = try alloc.alloc(bool, 2);
516 defer alloc.free(present);
517 present[0] = true;
518 present[1] = true;
519 for (tiles, 0..) |*t, i| {
520 t.* = Tile{
521 .r = .{ .target = .{ .sock = "/tmp/x" }, .label = "x", .session = "" },
522 .rect = .{ .top = 0, .left = 0, .rows = 24, .cols = 80 },
523 .shared = &shared,
524 .idx = i,
525 .wake_r = -1,
526 .wake_w = -1,
527 };
528 }
529 // Growth: without the re-cut the new tile sits on placeholder geometry
530 // and the old tile still owns the whole terminal — the screen keeps
531 // showing tile 0 while the keyboard types into tile 1's rows.
532 wv.focusAnswer(alloc, tiles, present, &shared, true, 1);
533 try std.testing.expectEqual(@as(u8, 1), shared.label_rows);
534 try std.testing.expect(tiles[0].resize_pending);
535 try std.testing.expect(tiles[1].resize_pending);
536 try std.testing.expectEqual(@as(usize, 1), shared.sel);
537 try std.testing.expect(tiles[1].claim_pending.load(.acquire));
538 tiles[0].resize_pending = false;
539 tiles[1].resize_pending = false;
540 // No growth: a full clear here would flash the wall for a focus move.
541 wv.focusAnswer(alloc, tiles, present, &shared, false, 0);
542 try std.testing.expect(!tiles[0].resize_pending);
543 try std.testing.expect(!tiles[1].resize_pending);
544 try std.testing.expectEqual(@as(usize, 0), shared.sel);
545 try std.testing.expect(tiles[0].claim_pending.load(.acquire));
546 }
547
548 test "a one-tile wall draws no label bar and paints row 1" {
549 const alloc = std.testing.allocator;
550 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
551 shared.tree = layout.Tree.init(alloc);
552 shared.flat_alloc = alloc;
553 defer shared.tree.deinit();
554 defer if (shared.last_flat) |*f| f.deinit(alloc);
555 defer if (shared.base_flat) |*f| f.deinit(alloc);
556 try shared.tree.addFirst(0);
557 const tiles = try alloc.alloc(Tile, 1);
558 defer alloc.free(tiles);
559 const present = try alloc.alloc(bool, 1);
560 defer alloc.free(present);
561 present[0] = true;
562 tiles[0] = Tile{
563 .r = .{ .target = .{ .sock = "/tmp/x" }, .label = "x", .session = "" },
564 .rect = .{ .top = 0, .left = 0, .rows = 24, .cols = 80 },
565 .shared = &shared,
566 .idx = 0,
567 .wake_r = -1,
568 .wake_w = -1,
569 };
570 wall_layout.relayout(alloc, tiles, present, &shared, 0);
571 // One tile: no label bar, every row is content.
572 try std.testing.expectEqual(@as(u8, 0), shared.label_rows);
573 try std.testing.expectEqual(@as(u16, 24), tiles[0].viewRows());
574 }
575
576 test "a relayout drops every tile's highlight, because the stripes move under it" {
577 // The drag is per-tile now, on each pump's Core, so relayout cannot
578 // reach it directly. It bumps repaint_gen instead, and every pump
579 // clears its own core.drag when it sees the generation move under it —
580 // a relayout re-cut the rect a held drag's anchor was resolved against.
581 // The generation is the whole mechanism, so it is what this pins.
582 const alloc = std.testing.allocator;
583 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
584 shared.tree = layout.Tree.init(alloc);
585 shared.flat_alloc = alloc;
586 defer shared.tree.deinit();
587 defer if (shared.last_flat) |*f| f.deinit(alloc);
588 defer if (shared.base_flat) |*f| f.deinit(alloc);
589 try shared.tree.addFirst(0);
590 try shared.tree.insert(0, 1);
591 const tiles = try alloc.alloc(Tile, 2);
592 defer alloc.free(tiles);
593 const present = try alloc.alloc(bool, 2);
594 defer alloc.free(present);
595 present[0] = true;
596 present[1] = true;
597 for (tiles, 0..) |*t, i| {
598 t.* = Tile{
599 .r = .{ .target = .{ .sock = "/tmp/x" }, .label = "x", .session = "" },
600 .rect = .{ .top = 0, .left = 0, .rows = 24, .cols = 80 },
601 .shared = &shared,
602 .idx = i,
603 .wake_r = -1,
604 .wake_w = -1,
605 };
606 }
607 const before = shared.repaint_gen.load(.acquire);
608 wall_layout.relayout(alloc, tiles, present, &shared, 0);
609 // One bump, every tile's pump clears its drag on it: the stripes moved,
610 // so a held drag's anchor names a rect that is somewhere else now.
611 try std.testing.expect(shared.repaint_gen.load(.acquire) > before);
612 }
613
614 test "saveLayoutTo writes the sidecar for the present tiles, spellings verbatim" {
615 // A wall saves its tree on the way out: the leaf spellings are the
616 // tiles' labels verbatim, so a reload restores the same view.
617 const alloc = std.testing.allocator;
618 var tmp = try TmpDir.make();
619 defer tmp.cleanup();
620 const path = try std.fmt.allocPrint(alloc, "{s}/layout", .{tmp.path()});
621 defer alloc.free(path);
622 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
623 shared.tree = layout.Tree.init(alloc);
624 shared.flat_alloc = alloc;
625 defer shared.tree.deinit();
626 defer if (shared.last_flat) |*f| f.deinit(alloc);
627 defer if (shared.base_flat) |*f| f.deinit(alloc);
628 try shared.tree.addFirst(0);
629 try shared.tree.splitRight(0, 1);
630 const tiles = try alloc.alloc(Tile, 2);
631 defer alloc.free(tiles);
632 for (tiles, 0..) |*t, i| {
633 t.* = Tile{
634 .r = .{
635 .target = .{ .sock = "/tmp/x" },
636 .label = switch (i) {
637 0 => "--sock /tmp/x#a",
638 else => "--sock /tmp/x#b",
639 },
640 .session = "",
641 },
642 .rect = .{ .top = 0, .left = 0, .rows = 24, .cols = 80 },
643 .shared = &shared,
644 .idx = i,
645 .wake_r = -1,
646 .wake_w = -1,
647 };
648 }
649 const present = [_]bool{ true, true };
650 wall_layout.saveLayoutTo(alloc, path, tiles, &present, &shared);
651 const got = wall.loadLayout(alloc, path) orelse return error.TestUnexpectedResult;
652 defer alloc.free(got);
653 try std.testing.expect(std.mem.startsWith(u8, got, "mux-layout 1\nbeside 0\n"));
654 try std.testing.expect(std.mem.indexOf(u8, got, "--sock /tmp/x#b\n") != null);
655 }
656
657 test "saveLayoutTo handles a vanished middle tile without panicking" {
658 // Tiles are never compacted (pump threads hold pointers into the
659 // array), so a vanished middle tile leaves a hole: present = {t,f,t}
660 // and the tree holds leaves 0 and 2. serialize indexes spellings by
661 // leaf ID, so the array must be tile-indexed — a dense array would
662 // panic on spellings[2] with only two entries.
663 const alloc = std.testing.allocator;
664 var tmp = try TmpDir.make();
665 defer tmp.cleanup();
666 const path = try std.fmt.allocPrint(alloc, "{s}/layout", .{tmp.path()});
667 defer alloc.free(path);
668 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
669 shared.tree = layout.Tree.init(alloc);
670 shared.flat_alloc = alloc;
671 defer shared.tree.deinit();
672 defer if (shared.last_flat) |*f| f.deinit(alloc);
673 defer if (shared.base_flat) |*f| f.deinit(alloc);
674 try shared.tree.addFirst(0);
675 try shared.tree.splitRight(0, 1);
676 try shared.tree.splitRight(1, 2);
677 // Tile 1 vanished: remove leaf 1 from the tree.
678 shared.tree.remove(1);
679 const tiles = try alloc.alloc(Tile, 3);
680 defer alloc.free(tiles);
681 for (tiles, 0..) |*t, i| {
682 t.* = Tile{
683 .r = .{
684 .target = .{ .sock = "/tmp/x" },
685 .label = switch (i) {
686 0 => "--sock /tmp/x#a",
687 1 => "--sock /tmp/x#b",
688 else => "--sock /tmp/x#c",
689 },
690 .session = "",
691 },
692 .rect = .{ .top = 0, .left = 0, .rows = 24, .cols = 80 },
693 .shared = &shared,
694 .idx = i,
695 .wake_r = -1,
696 .wake_w = -1,
697 };
698 }
699 const present = [_]bool{ true, false, true };
700 wall_layout.saveLayoutTo(alloc, path, tiles, &present, &shared);
701 const got = wall.loadLayout(alloc, path) orelse return error.TestUnexpectedResult;
702 defer alloc.free(got);
703 // Tile 2's label is verbatim; the hole (tile 1) is never serialized.
704 try std.testing.expect(std.mem.indexOf(u8, got, "--sock /tmp/x#c\n") != null);
705 try std.testing.expect(std.mem.indexOf(u8, got, "--sock /tmp/x#b") == null);
706 }
707
708 test "restore: a saved beside pair comes back verbatim on a tall terminal" {
709 // Aspect at 40x30 would say stacked; the sidecar says beside and wins.
710 const alloc = std.testing.allocator;
711 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 40, .rows = 30 }, .is_tty = false };
712 shared.tree = layout.Tree.init(alloc);
713 shared.flat_alloc = alloc;
714 defer shared.tree.deinit();
715 defer if (shared.last_flat) |*f| f.deinit(alloc);
716 defer if (shared.base_flat) |*f| f.deinit(alloc);
717 const resolved = [_]Resolved{
718 .{ .target = .{ .sock = "/tmp/x" }, .label = "--sock /tmp/x#a", .session = "a" },
719 .{ .target = .{ .sock = "/tmp/x" }, .label = "--sock /tmp/x#b", .session = "b" },
720 };
721 const bytes = "mux-layout 1\nbeside 0\n leaf 40 --sock /tmp/x#a\n leaf 39 --sock /tmp/x#b\n";
722 var focus_out: ?u8 = null;
723 try std.testing.expect(wall_layout.restoreLayout(alloc, &resolved, &shared, bytes, &focus_out));
724 const tiles = try alloc.alloc(Tile, 2);
725 defer alloc.free(tiles);
726 const present = [_]bool{ true, true };
727 for (tiles, 0..) |*t, i| {
728 t.* = Tile{
729 .r = resolved[i],
730 .rect = .{ .top = 0, .left = 0, .rows = 30, .cols = 40 },
731 .shared = &shared,
732 .idx = i,
733 .wake_r = -1,
734 .wake_w = -1,
735 };
736 }
737 wall_layout.relayout(alloc, tiles, &present, &shared, 0);
738 try std.testing.expectEqual(@as(u16, 0), tiles[1].rect.top);
739 try std.testing.expect(tiles[1].rect.left > 0);
740 }
741
742 test "restore: heals — unknown wall line inserted, lost leaf dropped" {
743 // Sidecar knows a, GONE and c side by side; the wall has a, c and NEW.
744 // Three saved leaves, not two: with two, dropping one collapses the
745 // container and every heal lands on the default cut, so the claim
746 // ("inserted BESIDE the match") could not be seen.
747 const alloc = std.testing.allocator;
748 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
749 shared.tree = layout.Tree.init(alloc);
750 shared.flat_alloc = alloc;
751 defer shared.tree.deinit();
752 defer if (shared.last_flat) |*f| f.deinit(alloc);
753 defer if (shared.base_flat) |*f| f.deinit(alloc);
754 const resolved = [_]Resolved{
755 .{ .target = .{ .sock = "/tmp/x" }, .label = "--sock /tmp/x#a", .session = "a" },
756 .{ .target = .{ .sock = "/tmp/x" }, .label = "--sock /tmp/x#c", .session = "c" },
757 .{ .target = .{ .sock = "/tmp/x" }, .label = "--sock /tmp/x#new", .session = "new" },
758 };
759 const bytes = "mux-layout 1\nbeside 0\n leaf 26 --sock /tmp/x#a\n" ++
760 " leaf 26 --sock /tmp/x#gone\n leaf 26 --sock /tmp/x#c\n";
761 var focus_out: ?u8 = null;
762 try std.testing.expect(wall_layout.restoreLayout(alloc, &resolved, &shared, bytes, &focus_out));
763 try std.testing.expectEqual(@as(usize, 3), shared.tree.count());
764 const flat = try shared.tree.flatten(alloc, 24, 80, wall_layout.wallFloors(3), null);
765 defer flat.deinit(alloc);
766 const a = flat.rectOf(0) orelse return error.TestUnexpectedResult;
767 const c = flat.rectOf(1) orelse return error.TestUnexpectedResult;
768 const new = flat.rectOf(2) orelse return error.TestUnexpectedResult;
769 // The saved axis survives the drop — a heal that rebuilt the default
770 // cut would stack these, giving them one left and three tops.
771 try std.testing.expectEqual(a.top, c.top);
772 try std.testing.expectEqual(a.top, new.top);
773 // NEW landed next to the match (a), not at either end by accident.
774 try std.testing.expect(a.left < new.left);
775 try std.testing.expect(new.left < c.left);
776 }
777
778 test "restore: zero matches or garbage degrade to false" {
779 const alloc = std.testing.allocator;
780 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
781 shared.tree = layout.Tree.init(alloc);
782 shared.flat_alloc = alloc;
783 defer shared.tree.deinit();
784 defer if (shared.last_flat) |*f| f.deinit(alloc);
785 defer if (shared.base_flat) |*f| f.deinit(alloc);
786 const resolved = [_]Resolved{
787 .{ .target = .{ .sock = "/tmp/x" }, .label = "--sock /tmp/x#a", .session = "a" },
788 };
789 var focus_out: ?u8 = null;
790 try std.testing.expect(!wall_layout.restoreLayout(alloc, &resolved, &shared, "not a sidecar", &focus_out));
791 const nomatch = "mux-layout 1\nleaf 0 --sock /nowhere#z\n";
792 try std.testing.expect(!wall_layout.restoreLayout(alloc, &resolved, &shared, nomatch, &focus_out));
793 }
794
795 test "restore: duplicate spellings pair positionally" {
796 // Wall shows one host twice: same spelling on both lines. Saved
797 // leaves 0 and 1 share it; leaf 0 takes wall 0, leaf 1 takes wall 1.
798 const alloc = std.testing.allocator;
799 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
800 shared.tree = layout.Tree.init(alloc);
801 shared.flat_alloc = alloc;
802 defer shared.tree.deinit();
803 defer if (shared.last_flat) |*f| f.deinit(alloc);
804 defer if (shared.base_flat) |*f| f.deinit(alloc);
805 const resolved = [_]Resolved{
806 .{ .target = .{ .sock = "/tmp/x" }, .label = "--sock /tmp/x#dup", .session = "dup" },
807 .{ .target = .{ .sock = "/tmp/x" }, .label = "--sock /tmp/x#dup", .session = "dup" },
808 };
809 // Lopsided on purpose: with near-equal weights a mispairing produces
810 // near-equal rects and hides in the leaf count.
811 const bytes = "mux-layout 1\nbeside 0\n leaf 59 --sock /tmp/x#dup\n leaf 20 --sock /tmp/x#dup\n";
812 var focus_out: ?u8 = null;
813 try std.testing.expect(wall_layout.restoreLayout(alloc, &resolved, &shared, bytes, &focus_out));
814 try std.testing.expectEqual(@as(usize, 2), shared.tree.count());
815 // Pairing is the whole claim and it IS observable: saved leaf 0 owns
816 // the left, wide slot, so it must be wall tile 0. Paired the other
817 // way round the count is still 2 and the tiles have swapped screens.
818 const flat = try shared.tree.flatten(alloc, 24, 80, wall_layout.wallFloors(2), null);
819 defer flat.deinit(alloc);
820 const t0 = flat.rectOf(0) orelse return error.TestUnexpectedResult;
821 const t1 = flat.rectOf(1) orelse return error.TestUnexpectedResult;
822 try std.testing.expect(t0.left < t1.left);
823 try std.testing.expect(t0.cols > t1.cols);
824 }
825
826 test "restoreLayoutFrom: dense sidecar indices land on the real tile indices" {
827 // Tile 1 was forgotten, so the dense array is 0,2,3 and the two index
828 // spaces disagree. With no hole they coincide and the second remap is
829 // untestable by construction — which is how a tile could be handed the
830 // rect flattened for its neighbour and no test would say so.
831 const alloc = std.testing.allocator;
832 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
833 shared.tree = layout.Tree.init(alloc);
834 shared.flat_alloc = alloc;
835 defer shared.tree.deinit();
836 defer if (shared.last_flat) |*f| f.deinit(alloc);
837 defer if (shared.base_flat) |*f| f.deinit(alloc);
838 const labels = [_][]const u8{
839 "--sock /tmp/x#a",
840 "--sock /tmp/x#hole",
841 "--sock /tmp/x#c",
842 "--sock /tmp/x#d",
843 };
844 var tiles: [4]Tile = undefined;
845 for (&tiles, 0..) |*t, i| {
846 t.* = .{
847 .r = .{ .target = .{ .sock = "/tmp/x" }, .label = labels[i], .session = "" },
848 .rect = .{ .top = 0, .left = 0, .rows = 24, .cols = 80 },
849 .shared = &shared,
850 .idx = i,
851 .wake_r = -1,
852 .wake_w = -1,
853 };
854 }
855 var present = [_]bool{ true, false, true, true };
856 // Three unequal weights over 80 columns less two rails: 40+20+18 = 78,
857 // so each tile's width names which saved leaf it was given.
858 const bytes = "mux-layout 1\nbeside 0\n leaf 40 --sock /tmp/x#a\n" ++
859 " leaf 20 --sock /tmp/x#c\n leaf 18 --sock /tmp/x#d\n";
860 var restored_focus: ?usize = null;
861 wall_layout.restoreLayoutFrom(alloc, &tiles, &present, 4, &shared, bytes, &restored_focus) orelse
862 return error.TestUnexpectedResult;
863 const flat = try shared.tree.flatten(alloc, 24, 80, wall_layout.wallFloors(3), null);
864 defer flat.deinit(alloc);
865 try std.testing.expectEqual(@as(?layout.Rect, null), flat.rectOf(1));
866 const a = flat.rectOf(0) orelse return error.TestUnexpectedResult;
867 const c = flat.rectOf(2) orelse return error.TestUnexpectedResult;
868 const d = flat.rectOf(3) orelse return error.TestUnexpectedResult;
869 try std.testing.expectEqual(@as(u16, 40), a.cols);
870 try std.testing.expectEqual(@as(u16, 20), c.cols);
871 try std.testing.expectEqual(@as(u16, 18), d.cols);
872 try std.testing.expect(a.left < c.left);
873 try std.testing.expect(c.left < d.left);
874 }
875
876 test "restore: focus_out maps the saved focus through the spelling match" {
877 // Saved leaf 1 matches wall tile 1 (b) → focus_out == 1. Saved leaf 1
878 // matches NO wall tile (gone) → focus_out stays null.
879 const alloc = std.testing.allocator;
880
881 // Matched: focus 1 → tile 1.
882 {
883 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
884 shared.tree = layout.Tree.init(alloc);
885 shared.flat_alloc = alloc;
886 defer shared.tree.deinit();
887 defer if (shared.last_flat) |*f| f.deinit(alloc);
888 defer if (shared.base_flat) |*f| f.deinit(alloc);
889 const resolved = [_]Resolved{
890 .{ .target = .{ .sock = "/tmp/x" }, .label = "--sock /tmp/x#a", .session = "a" },
891 .{ .target = .{ .sock = "/tmp/x" }, .label = "--sock /tmp/x#b", .session = "b" },
892 };
893 const bytes = "mux-layout 1\nbeside 0\n leaf 40 --sock /tmp/x#a\n leaf 39 --sock /tmp/x#b\nfocus 1\n";
894 var focus_out: ?u8 = null;
895 try std.testing.expect(wall_layout.restoreLayout(alloc, &resolved, &shared, bytes, &focus_out));
896 try std.testing.expectEqual(@as(?u8, 1), focus_out);
897 }
898
899 // Unmatched: saved leaf 1 (gone) has no wall tile → focus_out null.
900 {
901 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
902 shared.tree = layout.Tree.init(alloc);
903 shared.flat_alloc = alloc;
904 defer shared.tree.deinit();
905 defer if (shared.last_flat) |*f| f.deinit(alloc);
906 defer if (shared.base_flat) |*f| f.deinit(alloc);
907 const resolved = [_]Resolved{
908 .{ .target = .{ .sock = "/tmp/x" }, .label = "--sock /tmp/x#a", .session = "a" },
909 .{ .target = .{ .sock = "/tmp/x" }, .label = "--sock /tmp/x#new", .session = "new" },
910 };
911 const bytes = "mux-layout 1\nbeside 0\n leaf 40 --sock /tmp/x#a\n leaf 39 --sock /tmp/x#gone\nfocus 1\n";
912 var focus_out: ?u8 = null;
913 try std.testing.expect(wall_layout.restoreLayout(alloc, &resolved, &shared, bytes, &focus_out));
914 try std.testing.expectEqual(@as(?u8, null), focus_out);
915 }
916 }
src/tui/wall_test_picker.zig
Old New
@@ -0,0 +1,526 @@
1 //! The host picker popup (wall_picker.zig).
2 const std = @import("std");
3 const proto = @import("protocol");
4 const client = @import("client");
5 const interact = @import("interact");
6 const fixture = @import("wall_test_harness.zig");
7 const wall_host = @import("wall_host.zig");
8 const wall_picker = @import("wall_picker.zig");
9 const wv = @import("wallview.zig");
10 const Host = wall_host.Host;
11 const PickerAuto = wall_picker.PickerAuto;
12 const PickerBody = wall_picker.PickerBody;
13 const Shared = wv.Shared;
14 const Tile = wv.Tile;
15 const WallScreen = fixture.WallScreen;
16
17 test "paintPicker: the box is centred on the terminal, not pinned to the origin" {
18 const pipe = try std.posix.pipe2(.{ .NONBLOCK = true });
19 defer std.posix.close(pipe[0]);
20 defer std.posix.close(pipe[1]);
21 // Off-origin is the BASELINE here: a fixture on a terminal the box
22 // happens to fill is blind to every centring arithmetic mistake.
23 var shared = Shared{ .out_fd = pipe[1], .size = .{ .cols = 100, .rows = 40 }, .is_tty = true };
24 var table = [_]Host{
25 fixture.testHost(&shared, "a", "/tmp/a.sock"),
26 fixture.testHost(&shared, "b", "/tmp/b.sock"),
27 fixture.testHost(&shared, "c", "/tmp/c.sock"),
28 };
29 for (&table) |*h| {
30 fixture.setList(h, "");
31 h.applied = true;
32 }
33 var buf: [8192]u8 = undefined;
34 const frame = fixture.pickerFrame(&shared, pipe[0], &table, 0, &buf);
35 // Five rows (header + three hosts + footer) on forty: top = (40-5)/2.
36 // The box is the terminal's width here, so left is 0 and the columns
37 // are 1-based CUP.
38 try std.testing.expect(std.mem.indexOf(u8, frame, "\x1b[18;1H") != null);
39 try std.testing.expect(std.mem.indexOf(u8, frame, "\x1b[19;1H") != null);
40 try std.testing.expect(std.mem.indexOf(u8, frame, "\x1b[22;1H") != null);
41 // ...and nothing above or below the box.
42 try std.testing.expect(std.mem.indexOf(u8, frame, "\x1b[17;1H") == null);
43 try std.testing.expect(std.mem.indexOf(u8, frame, "\x1b[23;1H") == null);
44 // The cursor goes with the popup: a caret blinking in a tile says the
45 // keys are going there.
46 try std.testing.expect(std.mem.startsWith(u8, frame, "\x1b[?25l"));
47 }
48
49 test "paintPicker: a box wider than its cap is centred in the columns" {
50 const pipe = try std.posix.pipe2(.{ .NONBLOCK = true });
51 defer std.posix.close(pipe[0]);
52 defer std.posix.close(pipe[1]);
53 // Wider than `picker_row_max`, so the box is capped and the leftover
54 // columns are split: (200 - 128) / 2.
55 var shared = Shared{ .out_fd = pipe[1], .size = .{ .cols = 200, .rows = 10 }, .is_tty = true };
56 var table = [_]Host{fixture.testHost(&shared, "a", "/tmp/a.sock")};
57 fixture.setList(&table[0], "");
58 table[0].applied = true;
59 var buf: [8192]u8 = undefined;
60 const frame = fixture.pickerFrame(&shared, pipe[0], &table, 0, &buf);
61 try std.testing.expect(std.mem.indexOf(u8, frame, "\x1b[4;37H") != null);
62 }
63
64 test "paintPicker: a terminal too small for a box still says which popup has the keys" {
65 const pipe = try std.posix.pipe2(.{ .NONBLOCK = true });
66 defer std.posix.close(pipe[0]);
67 defer std.posix.close(pipe[1]);
68 var shared = Shared{ .out_fd = pipe[1], .size = .{ .cols = 20, .rows = 3 }, .is_tty = true };
69 var table = [_]Host{
70 fixture.testHost(&shared, "a", "/tmp/a.sock"),
71 fixture.testHost(&shared, "b", "/tmp/b.sock"),
72 };
73 for (&table) |*h| {
74 fixture.setList(h, "");
75 h.applied = true;
76 }
77 var buf: [8192]u8 = undefined;
78 const frame = fixture.pickerFrame(&shared, pipe[0], &table, 0, &buf);
79 try std.testing.expect(std.mem.indexOf(u8, frame, "hosts") != null);
80 // Under the minimum the header is the WHOLE popup: no rows, no legend.
81 // A box that does not fit is worse than a line saying which one it is.
82 try std.testing.expect(std.mem.indexOf(u8, frame, "Enter/c new session") == null);
83 try std.testing.expect(std.mem.indexOf(u8, frame, " 1 a") == null);
84 }
85
86 test "paintPicker: the window scrolls so the selected row is always on screen" {
87 const pipe = try std.posix.pipe2(.{ .NONBLOCK = true });
88 defer std.posix.close(pipe[0]);
89 defer std.posix.close(pipe[1]);
90 // Six rows for the box: header, footer, and four hosts of the eight.
91 var shared = Shared{ .out_fd = pipe[1], .size = .{ .cols = 60, .rows = 6 }, .is_tty = true };
92 const names = [_][]const u8{ "h0", "h1", "h2", "h3", "h4", "h5", "h6", "h7" };
93 var table: [8]Host = undefined;
94 for (&table, names) |*h, name| {
95 h.* = fixture.testHost(&shared, name, "/tmp/x.sock");
96 fixture.setList(h, "");
97 h.applied = true;
98 }
99 var buf: [8192]u8 = undefined;
100 const top = fixture.pickerFrame(&shared, pipe[0], &table, 0, &buf);
101 try std.testing.expect(std.mem.indexOf(u8, top, " 1> h0") != null);
102 try std.testing.expect(std.mem.indexOf(u8, top, "h7") == null);
103 // The last row: the window has to follow the selection, or `j` walks
104 // off the bottom of a box that never moves.
105 var buf2: [8192]u8 = undefined;
106 const bottom = fixture.pickerFrame(&shared, pipe[0], &table, 7, &buf2);
107 try std.testing.expect(std.mem.indexOf(u8, bottom, " 8> h7") != null);
108 try std.testing.expect(std.mem.indexOf(u8, bottom, "h0") == null);
109 }
110
111 test "paintPicker: a frame the stamp refuses to write does not eat the notice with it" {
112 const pipe = try std.posix.pipe2(.{ .NONBLOCK = true });
113 defer std.posix.close(pipe[0]);
114 defer std.posix.close(pipe[1]);
115 var shared = Shared{ .out_fd = pipe[1], .size = .{ .cols = 100, .rows = 40 }, .is_tty = true };
116 var table = [_]Host{fixture.testHost(&shared, "--sock /tmp/a.sock", "/tmp/a.sock")};
117 fixture.setList(&table[0], "");
118 table[0].applied = true;
119 wall_picker.paintPicker(&shared, &table, 0, null);
120
121 // A notice whose footer is byte-for-byte the legend already on the
122 // screen: the stamp refuses the write, and taking the notice ahead of
123 // that check is a sentence consumed by a frame nobody was sent.
124 wv.setNotice(&shared, " Enter/c new session x forget a add Esc");
125 wall_picker.paintPicker(&shared, &table, 0, null);
126 var buf: [96]u8 = undefined;
127 try std.testing.expectEqualStrings(
128 " Enter/c new session x forget a add Esc",
129 wv.takeNotice(&shared, &buf),
130 );
131 }
132
133 test "paintPicker: replayed into an engine, the popup covers its box and NOT one cell more" {
134 const alloc = std.testing.allocator;
135 const cols: u16 = 160;
136 const rows: u16 = 20;
137 var screen = try WallScreen.init(alloc, cols, rows);
138 defer screen.deinit();
139 // Off-origin in BOTH axes: `picker_row_max` caps the width at 128, so
140 // left = (160-128)/2, and three hosts make a five-row box at
141 // top = (20-5)/2. A grep over the frame's CUP sequences cannot see a
142 // paint that reaches a cell by another route — a different CUP form, a
143 // wider pad — so this one judges the GRID.
144 const w: u16 = @intCast(wall_picker.picker_row_max);
145 const left: u16 = (cols - w) / 2;
146 const height: u16 = 5;
147 const top: u16 = (rows - height) / 2;
148
149 // A background every untouched cell can be recognised by.
150 var bg: [cols]u8 = undefined;
151 @memset(&bg, '.');
152 var r: u16 = 0;
153 while (r < rows) : (r += 1) {
154 var cup: [16]u8 = undefined;
155 screen.eng.feed(std.fmt.bufPrint(&cup, "\x1b[{d};1H", .{r + 1}) catch unreachable);
156 screen.eng.feed(&bg);
157 }
158 screen.eng.feed("\x1b[H");
159
160 var shared = Shared{ .out_fd = screen.w, .size = .{ .cols = cols, .rows = rows }, .is_tty = true };
161 var table = [_]Host{
162 fixture.testHost(&shared, "--sock /tmp/a.sock", "/tmp/a.sock"),
163 fixture.testHost(&shared, "--sock /tmp/b.sock", "/tmp/b.sock"),
164 fixture.testHost(&shared, "--sock /tmp/c.sock", "/tmp/c.sock"),
165 };
166 for (&table) |*h| {
167 fixture.setList(h, "");
168 h.applied = true;
169 }
170 wall_picker.paintPicker(&shared, &table, 1, null);
171 screen.drain();
172 const dump = try screen.eng.dumpPlain(alloc);
173 defer alloc.free(dump);
174
175 var i: u16 = 0;
176 while (i < rows) : (i += 1) {
177 const l = WallScreen.line(dump, i) orelse return error.NoSuchRow;
178 try std.testing.expectEqual(@as(usize, cols), l.len);
179 if (i >= top and i < top + height) {
180 // The rails and every tile beside the box are outside it.
181 try std.testing.expectEqualStrings(bg[0..left], l[0..left]);
182 try std.testing.expectEqualStrings(bg[0..left], l[left + w ..]);
183 try std.testing.expect(!std.mem.eql(u8, bg[0..w], l[left..][0..w]));
184 } else {
185 try std.testing.expectEqualStrings(&bg, l);
186 }
187 }
188 // The selected row is the one the popup says it is, in the grid.
189 const sel_row = WallScreen.line(dump, top + 2) orelse return error.NoSuchRow;
190 try std.testing.expect(std.mem.indexOf(u8, sel_row, " 2> ") != null);
191 }
192
193 test "paintPicker: an unchanged frame is not written again" {
194 const pipe = try std.posix.pipe2(.{ .NONBLOCK = true });
195 defer std.posix.close(pipe[0]);
196 defer std.posix.close(pipe[1]);
197 var shared = Shared{ .out_fd = pipe[1], .size = .{ .cols = 60, .rows = 20 }, .is_tty = true };
198 var table = [_]Host{
199 fixture.testHost(&shared, "a", "/tmp/a.sock"),
200 fixture.testHost(&shared, "b", "/tmp/b.sock"),
201 };
202 for (&table) |*h| {
203 fixture.setList(h, "");
204 h.applied = true;
205 }
206 var buf: [8192]u8 = undefined;
207 try std.testing.expect(fixture.pickerFrame(&shared, pipe[0], &table, 0, &buf).len > 0);
208 // The pollers repaint this box once a second PER HOST. Rewriting an
209 // identical screen at that rate is a terminal that never goes quiet,
210 // which is also every `settle` in the e2e suite.
211 try std.testing.expectEqual(@as(usize, 0), fixture.pickerFrame(&shared, pipe[0], &table, 0, &buf).len);
212 // A moved selection is a different frame, and is written.
213 try std.testing.expect(fixture.pickerFrame(&shared, pipe[0], &table, 1, &buf).len > 0);
214 // ...and a cleared stamp is what a relayout leaves behind, so the box
215 // goes back onto a screen that was wiped under it.
216 shared.picker_stamp = 0;
217 try std.testing.expect(fixture.pickerFrame(&shared, pipe[0], &table, 1, &buf).len > 0);
218 }
219
220 test "PickerAuto: an empty wall opens the picker once, and a tile takes it back" {
221 var a: PickerAuto = .{};
222 // Empty and nothing open: the wall opens it itself, because a blank
223 // screen is no place to act from.
224 try std.testing.expectEqual(PickerAuto.Step.open, a.step(true, true, false, false));
225 // ...ONCE. The Esc that closed it has to leave the one-line text
226 // standing rather than being reopened over.
227 try std.testing.expectEqual(PickerAuto.Step.leave, a.step(true, true, false, false));
228 // A tile arrived: the wall has something to show and gets the screen.
229 try std.testing.expectEqual(PickerAuto.Step.close, a.step(true, false, true, false));
230 // ...and only once; the popup is already gone.
231 try std.testing.expectEqual(PickerAuto.Step.leave, a.step(true, false, false, false));
232 }
233
234 test "PickerAuto: a spelling in progress keeps the popup a tile would have closed" {
235 var a: PickerAuto = .{};
236 try std.testing.expectEqual(PickerAuto.Step.open, a.step(true, true, false, false));
237 // `prompting` layers OVER `picking`: closing on `picking` alone would
238 // take the editor off the screen and leave it eating every key, and its
239 // Enter would reach the main switch's dead `.add_tile` arm — no line,
240 // no notice, and no host added.
241 try std.testing.expectEqual(PickerAuto.Step.leave, a.step(true, false, true, true));
242 // The moment the line is submitted or cancelled, the close is owed again.
243 try std.testing.expectEqual(PickerAuto.Step.close, a.step(true, false, true, false));
244 }
245
246 test "PickerAuto: a picker the user opened is not closed by an arriving tile" {
247 var a: PickerAuto = .{};
248 try std.testing.expectEqual(PickerAuto.Step.open, a.step(true, true, false, false));
249 // Esc, and the user opens it again by hand. Without `taken` the flag
250 // outlives the close and the next birth force-closes a popup they are
251 // choosing from.
252 a.taken();
253 try std.testing.expectEqual(PickerAuto.Step.leave, a.step(true, false, true, false));
254 }
255
256 test "PickerAuto: the Esc that closes the wall's own popup is not undone by the wall" {
257 var a: PickerAuto = .{};
258 try std.testing.expectEqual(PickerAuto.Step.open, a.step(true, true, false, false));
259 // The close runs `taken` on EVERY exit, the wall's popup included, and
260 // the wall is still empty afterwards. One flag for both facts reopened
261 // the box over the one-line text the Esc had just asked for — and on
262 // that wall `Ctrl-\ d` never reached the keyboard again.
263 a.taken();
264 try std.testing.expectEqual(PickerAuto.Step.leave, a.step(true, true, false, false));
265 try std.testing.expectEqual(PickerAuto.Step.leave, a.step(true, true, false, false));
266 // A tile arrives and leaves again: THAT emptiness earns its own open.
267 try std.testing.expectEqual(PickerAuto.Step.leave, a.step(true, false, false, false));
268 try std.testing.expectEqual(PickerAuto.Step.open, a.step(true, true, false, false));
269 }
270
271 test "PickerAuto: one Esc leaves a user's picker that outlived the wall's last tile" {
272 var a: PickerAuto = .{};
273 // The user opens the popup on a wall that still has tiles, and the last
274 // of them ends underneath it. The emptiness the wall then sees is one
275 // whose popup is already up, so it is spent without ever opening.
276 try std.testing.expectEqual(PickerAuto.Step.leave, a.step(true, false, true, false));
277 try std.testing.expectEqual(PickerAuto.Step.leave, a.step(true, true, true, false));
278 a.taken();
279 // The one Esc that closed it is the whole of this: unspent, the wall
280 // answers it by opening the box straight back over the empty line.
281 try std.testing.expectEqual(PickerAuto.Step.leave, a.step(true, true, false, false));
282 }
283
284 test "PickerAuto: a wall still dialling spends nothing, so its first known emptiness still opens" {
285 var a: PickerAuto = .{};
286 // `is_tty` carries `restore_tried`: the user can open the picker by hand
287 // while the hosts are still answering, and a wall not yet KNOWN to be
288 // empty must not spend the open it has not made.
289 try std.testing.expectEqual(PickerAuto.Step.leave, a.step(false, true, true, false));
290 a.taken();
291 try std.testing.expectEqual(PickerAuto.Step.open, a.step(true, true, false, false));
292 }
293
294 test "PickerAuto: a wall still dialling, and a wall with no terminal, open nothing" {
295 var a: PickerAuto = .{};
296 // The caller folds `restore_tried` into this argument: a wall that has
297 // not heard from its hosts yet is not KNOWN to be empty, and a popup
298 // flashed over tiles that are about to arrive is one nobody asked for.
299 try std.testing.expectEqual(PickerAuto.Step.leave, a.step(false, true, false, false));
300 // A piped mux has no screen to put a popup on.
301 var b: PickerAuto = .{};
302 try std.testing.expectEqual(PickerAuto.Step.leave, b.step(false, true, false, false));
303 }
304
305 test "pickerRow: a table with a 10 in it pads every number, so no spelling shifts a column" {
306 var nine_buf: [96]u8 = undefined;
307 var ten_buf: [96]u8 = undefined;
308 const nine = wall_picker.pickerRow(&nine_buf, 9, true, "--sock /a", "1 session", false, 40);
309 const ten = wall_picker.pickerRow(&ten_buf, 10, true, "--sock /a", "1 session", false, 40);
310 try std.testing.expectEqual(
311 std.mem.indexOf(u8, nine, "--sock").?,
312 std.mem.indexOf(u8, ten, "--sock").?,
313 );
314
315 // A table that never reaches 10 spends no column on a digit it has not
316 // got: the marker is already two wide for the same reason.
317 var narrow_buf: [96]u8 = undefined;
318 const narrow = wall_picker.pickerRow(&narrow_buf, 9, false, "--sock /a", "1 session", false, 40);
319 try std.testing.expectEqualStrings(" 9 --sock", narrow[0..10]);
320 }
321
322 test "pickerRows: every host says what its poller last answered" {
323 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = true };
324 // Four hosts, one per state the column can report. A fixture of one
325 // reachable host is blind to three of them.
326 var table = [_]Host{
327 fixture.testHost(&shared, "--sock /tmp/a.sock", "/tmp/a.sock"),
328 fixture.testHost(&shared, "box", "/tmp/b.sock"),
329 fixture.testHost(&shared, "quic://gate:4433", "/tmp/c.sock"),
330 fixture.testHost(&shared, "slow", "/tmp/d.sock"),
331 };
332 fixture.setList(&table[0], "0\nwork\ndev\n");
333 table[0].applied = true;
334 fixture.setList(&table[1], "");
335 table[1].reachable.store(false, .release);
336 table[1].applied = true;
337 fixture.setList(&table[2], "");
338 table[2].applied = true;
339 // Never answered: `applied` is what a first list sets, so a host that
340 // has not reported once is `connecting` and not `no sessions` — the
341 // two send the user to opposite places.
342
343 var body: PickerBody = .{};
344 wall_picker.pickerRows(&body, &table, 2, 48);
345
346 try std.testing.expectEqual(@as(usize, 4), body.n);
347 try std.testing.expectEqualStrings(" 1 --sock /tmp/a.sock 3 sessions ", body.row(0));
348 try std.testing.expectEqualStrings(" 2 box unreachable ", body.row(1));
349 // The selected row wears the same marker a focused tile's bar does.
350 try std.testing.expectEqualStrings(" 3> quic://gate:4433 no sessions ", body.row(2));
351 try std.testing.expectEqualStrings(" 4 slow connecting ", body.row(3));
352 }
353
354 test "pickerRows: one session is not 1 sessions" {
355 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = true };
356 var table = [_]Host{fixture.testHost(&shared, "box", "/tmp/b.sock")};
357 fixture.setList(&table[0], "0\n");
358 table[0].applied = true;
359 var body: PickerBody = .{};
360 wall_picker.pickerRows(&body, &table, 0, 32);
361 try std.testing.expectEqualStrings(" 1> box 1 session ", body.row(0));
362 }
363
364 test "pickerRows: a spelling wider than the box keeps its tail" {
365 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = true };
366 var table = [_]Host{fixture.testHost(
367 &shared,
368 "--sock /run/user/1000/mux/very/long/path/to/muxd.sock",
369 "/tmp/b.sock",
370 )};
371 fixture.setList(&table[0], "0\n");
372 table[0].applied = true;
373 var body: PickerBody = .{};
374 wall_picker.pickerRows(&body, &table, 0, 32);
375 // Cut from the LEFT: the head of a socket path is what every host on
376 // one machine has in common, and the tail is what tells them apart.
377 try std.testing.expectEqualStrings(" 1> path/to/muxd.sock 1 session ", body.row(0));
378 }
379
380 test "pickBirth: Enter on an emptied popup births nothing, not a session on a host just forgotten" {
381 var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
382 defer arena.deinit();
383 const alloc = arena.allocator();
384 var shared: Shared = undefined;
385 fixture.stoppedWall(alloc, &shared);
386 var tiles: [4]Tile = undefined;
387 var present = [_]bool{false} ** 4;
388 var live: usize = 0;
389 defer fixture.endPumps(tiles[0..live]);
390 // TWO hosts, both forgotten: the wall the last `x` empties is the wall
391 // whose popup has no row left to move the selection to, so `sel` still
392 // indexes a slot — a one-host fixture proves nothing about the index.
393 var table = [_]Host{
394 fixture.testHost(&shared, "a", "/tmp/a.sock"),
395 fixture.testHost(&shared, "b", "/tmp/b.sock"),
396 };
397 for (&table) |*h| {
398 fixture.setList(h, "");
399 h.applied = true;
400 h.forgotten.store(true, .release);
401 }
402
403 const at = wall_picker.pickBirth(alloc, &tiles, &present, &live, &shared, &table, 1);
404
405 if (at != null) return error.BornOnAForgottenHost;
406 if (live != 0) return error.AForgottenHostTookASlot;
407 var buf: [96]u8 = undefined;
408 try std.testing.expectEqualStrings(
409 "[no hosts to start a session on - a adds one]",
410 wv.takeNotice(&shared, &buf),
411 );
412 }
413
414 test "pickBirth: Enter is an ask — the tile it births may start a daemon, the row it came from may not" {
415 var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
416 defer arena.deinit();
417 const alloc = arena.allocator();
418 var shared: Shared = undefined;
419 fixture.stoppedWall(alloc, &shared);
420 var tiles: [4]Tile = undefined;
421 var present = [_]bool{false} ** 4;
422 var live: usize = 0;
423 defer fixture.endPumps(tiles[0..live]);
424 // TWO hosts, and the birth on the second: a one-host fixture cannot
425 // see a birth that reaches for the wrong row.
426 var table = [_]Host{
427 .{
428 .spec = try wall_host.resolveHost(alloc, "alpha", null, client.quic_idle_ms_default),
429 .shared = &shared,
430 },
431 .{
432 .spec = try wall_host.resolveHost(alloc, "beta", null, client.quic_idle_ms_default),
433 .shared = &shared,
434 },
435 };
436 for (&table) |*h| {
437 fixture.setList(h, "");
438 h.applied = true;
439 }
440
441 const at = wall_picker.pickBirth(alloc, &tiles, &present, &live, &shared, &table, 1) orelse
442 return error.EnterBornNothing;
443
444 // The row Enter lands on is often exactly the one the poller calls
445 // unreachable, and starting that machine's daemon is what choosing it
446 // means. Nothing else on the wall may: the spec below it is what the
447 // poller re-dials every second.
448 try std.testing.expect(tiles[at].r.target.hand.asked);
449 try std.testing.expect(!table[1].spec.target.hand.asked);
450 try std.testing.expect(!table[1].spec.poll_target.hand.asked);
451 }
452
453 test "pickerRows: a forgotten host is off the list, and the numbers close up" {
454 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = true };
455 var table = [_]Host{
456 fixture.testHost(&shared, "a", "/tmp/a.sock"),
457 fixture.testHost(&shared, "b", "/tmp/b.sock"),
458 fixture.testHost(&shared, "c", "/tmp/c.sock"),
459 };
460 for (&table) |*h| {
461 fixture.setList(h, "");
462 h.applied = true;
463 }
464 // The slot STAYS — a poller thread holds the pointer and `Tile.host`
465 // indexes it — so only the list closes up.
466 table[1].forgotten.store(true, .release);
467 var body: PickerBody = .{};
468 wall_picker.pickerRows(&body, &table, 2, 32);
469 try std.testing.expectEqual(@as(usize, 2), body.n);
470 try std.testing.expectEqualStrings(" 1 a no sessions ", body.row(0));
471 try std.testing.expectEqualStrings(" 2> c no sessions ", body.row(1));
472 }
473
474 test "pickerRepaint: a screen cleared under the spelling editor still owes a paint, carrying the half-typed line" {
475 var f = interact.PrefixFilter{};
476 var buf: [interact.PrefixFilter.prompt_max + 4]u8 = undefined;
477
478 // Nothing is owed while the popup is not up, whatever else happened.
479 try std.testing.expect(!wall_picker.pickerRepaint(&buf, &f, true).due);
480
481 f.picking = true;
482 const closed = wall_picker.pickerRepaint(&buf, &f, false);
483 try std.testing.expect(!closed.due);
484 try std.testing.expectEqual(@as(?[]const u8, null), closed.line);
485
486 // The editor is open and a poll on some OTHER host births or vanishes a
487 // tile: `relayout` clears the whole screen and zeroes the stamp, which
488 // is the trigger. Skipping the paint here leaves the user typing into an
489 // editor that is not on the screen, and every key still goes to it.
490 f.prompting = true;
491 var typed = [_]u8{ 'b', 'o', 'x' };
492 for (&typed) |*c| _ = f.feed(c[0..1]);
493 const repair = wall_picker.pickerRepaint(&buf, &f, true);
494 try std.testing.expect(repair.due);
495 try std.testing.expectEqualStrings(": box_", repair.line orelse "");
496 }
497
498 test "hostState: the row counts the sessions the wall could show, not the runs in the reply" {
499 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
500 var h = Host{
501 .spec = .{ .spelling = "--sock /a", .target = .{ .sock = "/a" }, .poll_target = .{ .sock = "/a" } },
502 .shared = &shared,
503 };
504 var buf: [32]u8 = undefined;
505
506 // Never polled, so nothing is known: `no sessions` here would send the
507 // user to birth on a machine that is not answering.
508 try std.testing.expectEqualStrings("connecting", wall_picker.hostState(&buf, &h));
509
510 h.applied = true;
511 const listed = "a\nb\nc\n";
512 @memcpy(h.list[0..listed.len], listed);
513 h.list_len = listed.len;
514 try std.testing.expectEqualStrings("3 sessions", wall_picker.hostState(&buf, &h));
515
516 // A row saying `4 sessions` beside three tiles is the row lying about
517 // the wall: `planHostDiff` births through `validSessionName`, so the
518 // count reads the reply through the same filter.
519 const with_junk = "a\nb\n" ++ ("x" ** (proto.session_name_max + 1)) ++ "\nc\n";
520 @memcpy(h.list[0..with_junk.len], with_junk);
521 h.list_len = with_junk.len;
522 try std.testing.expectEqualStrings("3 sessions", wall_picker.hostState(&buf, &h));
523
524 h.reachable.store(false, .release);
525 try std.testing.expectEqualStrings("unreachable", wall_picker.hostState(&buf, &h));
526 }
src/tui/wall_test_pump.zig
Old New
@@ -0,0 +1,530 @@
1 //! One tile's pump thread (wall_pump.zig).
2 const std = @import("std");
3 const proto = @import("protocol");
4 const client = @import("client");
5 const interact = @import("interact");
6 const TmpDir = @import("testtmp").TmpDir;
7 const fixture = @import("wall_test_harness.zig");
8 const wall_pump = @import("wall_pump.zig");
9 const wv = @import("wallview.zig");
10 const AgentLocal = wall_pump.AgentLocal;
11 const ClaimStep = wall_pump.ClaimStep;
12 const Shared = wv.Shared;
13 const SwallowTransport = fixture.SwallowTransport;
14 const Tile = wv.Tile;
15
16 test "claimAllowed: a stale arm does not claim even when the sink would now admit it" {
17 const alloc = std.testing.allocator;
18 const pipe = try std.posix.pipe2(.{ .NONBLOCK = true });
19 defer std.posix.close(pipe[0]);
20 defer std.posix.close(pipe[1]);
21 var shared = Shared{ .out_fd = pipe[1], .size = .{ .cols = 80, .rows = 24 }, .is_tty = true };
22 // The focus has moved to tile 1; tile 2 is still holding the arm it was
23 // given before the popup went up.
24 shared.sel = 1;
25 var t = fixture.claimBench(&shared, 2);
26 t.claim_pending.store(true, .release);
27 var core = try interact.Core.initSized(alloc, -1, pipe[1], .{ .cols = 80, .rows = 24 });
28 defer core.deinit();
29 core.is_tty = true;
30
31 // The whole of this test: the picker has CLOSED, so the default sink
32 // admits every paint and `claimTerminal` would SUCCEED. Judging the
33 // claim's refusal cannot see that — there is no refusal left to judge —
34 // so the focus test has to run first, and `claimFocus` is the one door
35 // in this file that puts the two in that order.
36 const rect: proto.Size = .{ .cols = 80, .rows = 24 };
37 _ = t.claim_pending.swap(false, .acq_rel);
38 try std.testing.expectEqual(ClaimStep.dropped, wall_pump.claimFocus(&t, &core, rect));
39
40 // Nothing claimed, and nothing on the terminal: `session_claim` is the
41 // mouse modes, and a second tile setting them is what leaves one
42 // session's modes on the screen with nothing left to take them off.
43 try std.testing.expectEqual(interact.Claim.none, core.claim);
44 var buf: [256]u8 = undefined;
45 try std.testing.expectEqual(@as(usize, 0), std.posix.read(pipe[0], &buf) catch 0);
46
47 // The control: the same call on the tile that DOES hold the focus takes
48 // it, so the guard is a focus test and not a blanket refusal.
49 shared.sel = 2;
50 try std.testing.expectEqual(ClaimStep.done, wall_pump.claimFocus(&t, &core, rect));
51 try std.testing.expectEqual(interact.Claim.session, core.claim);
52 try std.testing.expect((std.posix.read(pipe[0], &buf) catch 0) > 0);
53 }
54
55 test "afterClaim: a claim the picker refused is armed again for this tile" {
56 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = true };
57 // Off-origin: tile 2 of a wall, not tile 0, so an index compared
58 // against a hard-coded 0 would pass here and nowhere else.
59 shared.sel = 2;
60 var t = fixture.claimBench(&shared, 2);
61 try std.testing.expectEqual(ClaimStep.rearmed, wall_pump.afterClaim(&t, false, false, true));
62 try std.testing.expect(t.claim_pending.load(.acquire));
63 }
64
65 test "afterClaim: an arm does not outlive the focus that earned it" {
66 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = true };
67 var t = fixture.claimBench(&shared, 2);
68 // The keyboard moved the focus while the popup was up — a poller's tile
69 // arriving, or the birth the Enter made. A re-arm that survived it
70 // fires after the close: two tiles' modes on one terminal, the cursor
71 // resting on the loser, and the release already consumed so nothing
72 // takes either off.
73 shared.sel = 1;
74 try std.testing.expectEqual(ClaimStep.dropped, wall_pump.afterClaim(&t, false, false, true));
75 try std.testing.expect(!t.claim_pending.load(.acquire));
76 }
77
78 test "afterClaim: only the SINK's refusal is worth another pass" {
79 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = true };
80 shared.sel = 2;
81 var t = fixture.claimBench(&shared, 2);
82 // A claim that landed owes nothing.
83 try std.testing.expectEqual(ClaimStep.done, wall_pump.afterClaim(&t, true, true, true));
84 // A Core that ALREADY holds it answers false too, and re-arming that
85 // would re-take the focus notice on every pass, forever.
86 try std.testing.expectEqual(ClaimStep.done, wall_pump.afterClaim(&t, false, true, true));
87 // No terminal, nothing to hold: a piped `mux` is one tile and no claim.
88 try std.testing.expectEqual(ClaimStep.done, wall_pump.afterClaim(&t, false, false, false));
89 // None of the three armed anything.
90 try std.testing.expect(!t.claim_pending.load(.acquire));
91 }
92
93 test "tilePaintBegin: no tile paints while the picker owns the screen" {
94 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = true };
95 var t = Tile{
96 .r = .{ .target = .{ .sock = "/tmp/x" }, .label = "x", .session = "0" },
97 .rect = .{ .top = 0, .left = 0, .rows = 24, .cols = 80 },
98 .shared = &shared,
99 .idx = 0,
100 .wake_r = -1,
101 .wake_w = -1,
102 };
103 // The gate the whole popup rests on, and the gate that makes a focus
104 // CLAIM fail under it (`Core.claimTerminal` writes through the same
105 // sink) — which is why the pump re-arms `claim_pending` instead of
106 // treating a false return as "already claimed".
107 try std.testing.expect(wall_pump.tilePaintBegin(&t));
108 wall_pump.tilePaintEnd(&t);
109 shared.picker_open.store(true, .release);
110 try std.testing.expect(!wall_pump.tilePaintBegin(&t));
111 // Refused WITHOUT holding the lock: a begin that returned false and
112 // kept `paint_mu` would wedge the keyboard on its next paint.
113 try std.testing.expect(shared.paint_mu.tryLock());
114 shared.paint_mu.unlock();
115 }
116
117 test "endRefusal: a refusal is said in this client's words, never in the peer's bytes" {
118 try std.testing.expectEqualStrings(
119 "[no such session on that daemon]",
120 wall_pump.endRefusal(proto.end_reason.no_session),
121 );
122 try std.testing.expectEqualStrings(
123 "[the daemon could not read the end request]",
124 wall_pump.endRefusal(proto.end_reason.bad_frame),
125 );
126 // Anything the daemon does not say: an OSC title set, a CSI, or 260
127 // bytes of nothing. All three used to reach the terminal verbatim.
128 try std.testing.expectEqualStrings(
129 "[the daemon refused to end this session]",
130 wall_pump.endRefusal("\x1b]0;pwned\x07"),
131 );
132 try std.testing.expectEqualStrings(
133 "[the daemon refused to end this session]",
134 wall_pump.endRefusal("\x1b[2J"),
135 );
136 try std.testing.expectEqualStrings("[the daemon refused to end this session]", wall_pump.endRefusal(""));
137 }
138
139 test "a copy too big for OSC 52 is said out loud rather than dropped" {
140 // The notice path is the whole claim. A reply that rounds to ok on the
141 // wire but outruns OSC 52 is neither trimmed nor dropped: a half-copied
142 // selection is worse than none, because the user finds out when they
143 // paste it. The drag is per-tile now, on the Core the pump owns, so the
144 // copy goes through that Core — no claim is taken, the copy reads the
145 // drag and not the terminal's modes.
146 const alloc = std.testing.allocator;
147 const p = try std.posix.pipe2(.{ .NONBLOCK = true });
148 defer std.posix.close(p[0]);
149 defer std.posix.close(p[1]);
150 var shared = Shared{ .out_fd = p[1], .size = .{ .cols = 80, .rows = 24 }, .is_tty = true };
151 var t = Tile{
152 .r = .{ .target = .{ .sock = "/s" }, .label = "a", .session = "a" },
153 .rect = .{ .top = 0, .left = 0, .rows = 24, .cols = 80 },
154 .shared = &shared,
155 .idx = 0,
156 .wake_r = -1,
157 .wake_w = -1,
158 };
159 var core = try interact.Core.initSized(alloc, -1, p[1], shared.size);
160 defer core.deinit();
161 core.rep.history_rows = 0;
162 core.drag.press(.{ .row = 3, .col = 4 }, .{ .tile = 0, .row = 0, .col = 4 });
163 core.drag.motion(.{ .row = 5, .col = 9 }, .{ .tile = 0, .row = 2, .col = 9 });
164 _ = core.drag.release();
165 try std.testing.expect(core.drag.range() != null);
166 var tr: SwallowTransport = .{};
167 const held = core.drag.range() orelse return error.NoDrag;
168 try core.requestSelection(&tr, held);
169
170 // Base64 of this length overruns clipboard_base64_max: the daemon sends
171 // it as ok, and OSC 52 cannot carry it.
172 const fits = proto.clipboard_base64_max / 4 * 3;
173 const big = try alloc.alloc(u8, fits + 1);
174 defer alloc.free(big);
175 @memset(big, 'x');
176 const rbuf = try alloc.alloc(u8, proto.selection_reply_prefix_len + big.len);
177 defer alloc.free(rbuf);
178 const payload = fixture.replyBytes(rbuf, core.sel_id, .ok, 0, big);
179
180 wall_pump.copySelection(&t, alloc, &core, payload);
181 var buf: [8192]u8 = undefined;
182 const said = fixture.readAvail(p[0], &buf);
183 try std.testing.expect(std.mem.indexOf(u8, said, "too large") != null);
184 // Refused, not trimmed: a half-copied selection is worse than none,
185 // because the user finds out when they paste it.
186 try std.testing.expect(std.mem.indexOf(u8, said, "\x1b]52;") == null);
187 }
188
189 test "a reconnect drops the highlight over its own tile, and only its own" {
190 // Core.reattached clears that Core's drag — a resync renames the
191 // absolute row space, so a highlight kept across one would invert rows
192 // nobody selected. The drag is per-tile, so one tile's reconnect says
193 // nothing about another's selection: the neighbour's highlight stays.
194 const alloc = std.testing.allocator;
195 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
196 var tiles: [2]Tile = undefined;
197 tiles[0] = Tile{
198 .r = .{ .target = .{ .sock = "/s" }, .label = "a", .session = "a" },
199 .rect = .{ .top = 0, .left = 0, .rows = 12, .cols = 80 },
200 .shared = &shared,
201 .idx = 0,
202 .wake_r = -1,
203 .wake_w = -1,
204 };
205 tiles[1] = Tile{
206 .r = .{ .target = .{ .sock = "/s" }, .label = "b", .session = "b" },
207 .rect = .{ .top = 12, .left = 0, .rows = 12, .cols = 80 },
208 .shared = &shared,
209 .idx = 1,
210 .wake_r = -1,
211 .wake_w = -1,
212 };
213 // Each tile's pump owns its own Core, and each Core its own drag. A
214 // drag over tile 0 and one over tile 1, anchored in their own tiles.
215 var core0 = try interact.Core.initSized(alloc, -1, -1, shared.size);
216 defer core0.deinit();
217 var core1 = try interact.Core.initSized(alloc, -1, -1, shared.size);
218 defer core1.deinit();
219 core0.drag.press(.{ .row = 3, .col = 4 }, .{ .tile = 0, .row = 0, .col = 4 });
220 core0.drag.motion(.{ .row = 5, .col = 9 }, .{ .tile = 0, .row = 2, .col = 9 });
221 core1.drag.press(.{ .row = 3, .col = 4 }, .{ .tile = 1, .row = 0, .col = 4 });
222 core1.drag.motion(.{ .row = 5, .col = 9 }, .{ .tile = 1, .row = 2, .col = 9 });
223 try std.testing.expectEqual(@as(?usize, 0), core0.drag.on());
224 try std.testing.expectEqual(@as(?usize, 1), core1.drag.on());
225
226 // Tile 0 reconnects: its row space is about to be renamed, so its
227 // highlight comes off. Tile 1's never went away.
228 core0.reattached();
229 try std.testing.expect(core0.drag.range() == null);
230 try std.testing.expect(core0.drag.on() == null);
231 try std.testing.expect(core1.drag.range() != null);
232 try std.testing.expectEqual(@as(?usize, 1), core1.drag.on());
233 }
234
235 test "agent channels: slots fill in order, a full table refuses, and a close is announced" {
236 // The pump's table at the size its refusal path needs. The e2e proves
237 // one channel end to end and will never fill eight of them.
238 var locals: [3]?AgentLocal = @splat(null);
239
240 // Real fds, not stand-ins: a channel here is the write end of a pipe, so
241 // "the table closed it" is answered by the READER seeing EOF rather than
242 // by trusting the table's own bookkeeping. Non-blocking, so a channel
243 // that was NOT closed reports EAGAIN and fails the assertion instead of
244 // parking the suite on a read that never returns.
245 var peers: [4]std.posix.fd_t = undefined;
246 var chans: [4]std.posix.fd_t = undefined;
247 for (0..4) |i| {
248 const p = try std.posix.pipe2(.{ .NONBLOCK = true });
249 peers[i] = p[0];
250 chans[i] = p[1];
251 }
252 defer for (peers) |fd| std.posix.close(fd);
253
254 for (0..3) |i| try std.testing.expectEqual(
255 @as(?usize, i),
256 wall_pump.storeLocal(&locals, @intCast(10 + i), chans[i]),
257 );
258 // The full table the pump answers with `agent_close`. The fourth fd stays
259 // this test's to close: a refused store never took ownership of it.
260 try std.testing.expectEqual(@as(?usize, null), wall_pump.storeLocal(&locals, 99, chans[3]));
261 std.posix.close(chans[3]);
262
263 try std.testing.expectEqual(@as(?usize, 1), wall_pump.findLocal(&locals, 11));
264 try std.testing.expectEqual(@as(?usize, null), wall_pump.findLocal(&locals, 99));
265
266 // A pipe for the link, which is what a `.fd` transport writes to. The
267 // frame that comes back out of it is the daemon's only notice that this
268 // end hung up on the channel.
269 const link = try std.posix.pipe2(.{ .NONBLOCK = true });
270 defer std.posix.close(link[0]);
271 defer std.posix.close(link[1]);
272 var transport: client.Transport = .{ .conn = .{ .r = link[0], .w = link[1] }, .link = .fd };
273
274 wall_pump.closeLocal(&locals, 1, &transport);
275 try std.testing.expectEqual(@as(?AgentLocal, null), locals[1]);
276 try std.testing.expect(fixture.peerClosed(peers[1]));
277
278 const frame = (try proto.readFrame(std.testing.allocator, link[0])) orelse
279 return error.NoAgentCloseOnLocalClose;
280 defer frame.deinit(std.testing.allocator);
281 try std.testing.expectEqual(proto.MsgType.agent_close, frame.type);
282 try std.testing.expectEqual(@as(u32, 11), try proto.decodeAgentId(frame.payload));
283
284 // The redial's sweep: every channel still open goes, and nothing is said
285 // — the connection that owned them is the one that just died.
286 wall_pump.dropLocals(&locals);
287 for (locals) |c| try std.testing.expectEqual(@as(?AgentLocal, null), c);
288 try std.testing.expect(fixture.peerClosed(peers[0]));
289 try std.testing.expect(fixture.peerClosed(peers[2]));
290 }
291
292 test "agent channels: an oversize frame hangs the channel up, a full one lands" {
293 var locals: [2]?AgentLocal = @splat(null);
294 const chan = try std.posix.pipe2(.{ .NONBLOCK = true });
295 defer std.posix.close(chan[0]);
296 _ = wall_pump.storeLocal(&locals, 5, chan[1]);
297
298 const link = try std.posix.pipe2(.{ .NONBLOCK = true });
299 defer std.posix.close(link[0]);
300 defer std.posix.close(link[1]);
301 var transport: client.Transport = .{ .conn = .{ .r = link[0], .w = link[1] }, .link = .fd };
302
303 // The ordinary case first, so the refusal below is a refusal and not a
304 // delivery path that never worked.
305 var small: [proto.agent_id_len + 2]u8 = undefined;
306 small[0..proto.agent_id_len].* = proto.encodeAgentId(5);
307 @memcpy(small[proto.agent_id_len..], "hi");
308 try std.testing.expect(wall_pump.deliverAgentData(&locals, &small, &transport));
309 var got: [8]u8 = undefined;
310 try std.testing.expectEqual(@as(usize, 2), try std.posix.read(chan[0], &got));
311 try std.testing.expectEqualSlices(u8, "hi", got[0..2]);
312
313 // One byte past the cap. The daemon never sends this; a peer that is not
314 // the daemon we shipped can, and `writeAllFd` would block on it.
315 const over = try std.testing.allocator.alloc(u8, proto.agent_id_len + proto.agent_data_max + 1);
316 defer std.testing.allocator.free(over);
317 @memset(over, 'x');
318 over[0..proto.agent_id_len].* = proto.encodeAgentId(5);
319 try std.testing.expect(wall_pump.deliverAgentData(&locals, over, &transport));
320 try std.testing.expectEqual(@as(?AgentLocal, null), locals[0]);
321 try std.testing.expect(fixture.peerClosed(chan[0]));
322
323 // And the daemon is told, because it is holding the far socket open for
324 // an answer this end will never write.
325 // A payload too short to name a channel: false, and the pump abandons the
326 // batch on it rather than skipping one frame, because a stream that has
327 // lost frame alignment is not one to keep reading.
328 try std.testing.expect(!wall_pump.deliverAgentData(&locals, "ab", &transport));
329
330 const frame = (try proto.readFrame(std.testing.allocator, link[0])) orelse
331 return error.NoAgentCloseOnOversize;
332 defer frame.deinit(std.testing.allocator);
333 try std.testing.expectEqual(proto.MsgType.agent_close, frame.type);
334 try std.testing.expectEqual(@as(u32, 5), try proto.decodeAgentId(frame.payload));
335 }
336
337 test "agent forwarding is per tile: no -A offers nothing and opens nothing" {
338 // A REAL agent socket on this machine, listening. Without one, a tile
339 // that never asked for forwarding and a tile that did both refuse — for
340 // different reasons — and the consent gate could be deleted under a
341 // green suite.
342 var tmp = try TmpDir.make();
343 defer tmp.cleanup();
344 var buf: [128]u8 = undefined;
345 const sock = try std.fmt.bufPrintZ(&buf, "{s}/agent.sock", .{tmp.path()});
346 const addr = try std.net.Address.initUnix(sock);
347 var listener = try addr.listen(.{});
348 defer listener.deinit();
349
350 var locals: [2]?AgentLocal = @splat(null);
351 defer wall_pump.dropLocals(&locals);
352
353 // The gate, both ways round, against the same reachable agent.
354 try std.testing.expect(!wall_pump.openAgentChan(&locals, 1, false, sock));
355 try std.testing.expectEqual(@as(?usize, null), wall_pump.findLocal(&locals, 1));
356 try std.testing.expect(wall_pump.openAgentChan(&locals, 1, true, sock));
357 try std.testing.expect(wall_pump.findLocal(&locals, 1) != null);
358
359 // And the daemon does not get told about an agent the tile never
360 // offered — the offer is what the daemon routes on, so a stray one
361 // makes this client the answerer for a session it never armed.
362 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
363 var t = Tile{
364 .r = .{ .target = .{ .sock = "/tmp/x" }, .label = "x", .session = "", .agent = false },
365 .rect = .{ .top = 0, .left = 0, .rows = 4, .cols = 80 },
366 .shared = &shared,
367 .idx = 0,
368 .wake_r = -1,
369 .wake_w = -1,
370 };
371 const link = try std.posix.pipe2(.{ .NONBLOCK = true });
372 defer std.posix.close(link[0]);
373 defer std.posix.close(link[1]);
374 var transport: client.Transport = .{ .conn = .{ .r = link[0], .w = link[1] }, .link = .fd };
375
376 try wall_pump.sendAttach(&t, &transport, 0, 0);
377 const first = (try proto.readFrame(std.testing.allocator, link[0])) orelse
378 return error.NoAttach;
379 defer first.deinit(std.testing.allocator);
380 try std.testing.expectEqual(proto.MsgType.attach, first.type);
381 // Nothing else on the pipe at all: an empty non-blocking read is the
382 // only proof that no offer followed, since a frame reader would just
383 // block waiting for one.
384 var spare: [1]u8 = undefined;
385 try std.testing.expectError(error.WouldBlock, std.posix.read(link[0], &spare));
386
387 // The positive control on the same pipe: with `-A` the offer follows the
388 // attach, so the silence above is the gate and not an unwritten frame.
389 t.r.agent = true;
390 try wall_pump.sendAttach(&t, &transport, 0, 0);
391 const attach = (try proto.readFrame(std.testing.allocator, link[0])).?;
392 defer attach.deinit(std.testing.allocator);
393 const offer = (try proto.readFrame(std.testing.allocator, link[0])) orelse
394 return error.NoAgentOffer;
395 defer offer.deinit(std.testing.allocator);
396 try std.testing.expectEqual(proto.MsgType.agent_offer, offer.type);
397 }
398
399 test "a view tile's attach makes no size claim; a tile the user asked for does" {
400 // The daemon reads attach-or-create off the attach's size claim: 0x0
401 // joins, a real size creates. So a view tile (creates = false) must
402 // put 0x0 on the wire and leave its rect to the resize doorbell, while
403 // a tile the user asked for (creates = true) claims its rect in the
404 // attach itself.
405 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
406 // A one-tile wall: no label bar, so viewRows is the whole stripe.
407 shared.label_rows = 0;
408 var t = Tile{
409 .r = .{ .target = .{ .sock = "/tmp/x" }, .label = "x", .session = "", .agent = false },
410 .rect = .{ .top = 0, .left = 0, .rows = 24, .cols = 80 },
411 .shared = &shared,
412 .idx = 0,
413 .wake_r = -1,
414 .wake_w = -1,
415 };
416 const link = try std.posix.pipe2(.{ .NONBLOCK = true });
417 defer std.posix.close(link[0]);
418 defer std.posix.close(link[1]);
419 var transport: client.Transport = .{ .conn = .{ .r = link[0], .w = link[1] }, .link = .fd };
420
421 // A view tile: 0x0 on the wire, and the rect owed to the doorbell.
422 t.creates = false;
423 t.resize_pending = false;
424 try wall_pump.sendAttach(&t, &transport, 0, 0);
425 const view = (try proto.readFrame(std.testing.allocator, link[0])) orelse
426 return error.NoAttach;
427 defer view.deinit(std.testing.allocator);
428 try std.testing.expectEqual(proto.MsgType.attach, view.type);
429 const view_req = try proto.decodeAttach(view.payload);
430 try std.testing.expectEqual(@as(u16, 0), view_req.cols);
431 try std.testing.expectEqual(@as(u16, 0), view_req.rows);
432 try std.testing.expect(t.resize_pending);
433
434 // The same tile, now one the user asked for: the rect is in the attach,
435 // and no doorbell is owed.
436 t.creates = true;
437 t.resize_pending = false;
438 try wall_pump.sendAttach(&t, &transport, 0, 0);
439 const entry = (try proto.readFrame(std.testing.allocator, link[0])) orelse
440 return error.NoAttach;
441 defer entry.deinit(std.testing.allocator);
442 try std.testing.expectEqual(proto.MsgType.attach, entry.type);
443 const entry_req = try proto.decodeAttach(entry.payload);
444 try std.testing.expectEqual(@as(u16, 80), entry_req.cols);
445 try std.testing.expectEqual(@as(u16, 24), entry_req.rows);
446 try std.testing.expect(!t.resize_pending);
447 }
448
449 test "a newborn tile's first claim is its real stripe, not the placeholder" {
450 // A chord-born tile (creates = true) puts its rect on the attach frame,
451 // and the daemon refuses creates under min_session_rows. A 2-row
452 // stripe under a label bar is 1 content row — below the floor — so a
453 // placeholder stripe silently freezes the session on a stale grid.
454 // The tile's stripe must be what relayout would give it BEFORE the
455 // pump's first sendAttach.
456 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
457 shared.label_rows = 1; // two tiles draw a bar
458 // Real stripe for the second tile of a 2-tile wall on 24 rows.
459 var t = Tile{
460 .r = .{ .target = .{ .sock = "/tmp/x" }, .label = "x", .session = "", .agent = false },
461 .rect = .{ .top = 12, .left = 0, .rows = 12, .cols = 80 },
462 .shared = &shared,
463 .idx = 1,
464 .wake_r = -1,
465 .wake_w = -1,
466 };
467 t.creates = true;
468 const link = try std.posix.pipe2(.{ .NONBLOCK = true });
469 defer std.posix.close(link[0]);
470 defer std.posix.close(link[1]);
471 var transport: client.Transport = .{ .conn = .{ .r = link[0], .w = link[1] }, .link = .fd };
472 try wall_pump.sendAttach(&t, &transport, 0, 0);
473 const fr = (try proto.readFrame(std.testing.allocator, link[0])) orelse
474 return error.NoAttach;
475 defer fr.deinit(std.testing.allocator);
476 const req = try proto.decodeAttach(fr.payload);
477 // The real stripe's viewRows is 11 (12 - 1 bar), never the 1 a
478 // placeholder rows=2 stripe would put on the wire.
479 try std.testing.expectEqual(@as(u16, 80), req.cols);
480 try std.testing.expectEqual(@as(u16, 11), req.rows);
481 try std.testing.expect(req.rows >= proto.min_session_rows);
482 }
483
484 test "the cursor sleeps in the focused tile, whoever painted last" {
485 // Every painter ends by showing the cursor where its own stripe sits, so
486 // without an owner the visible cursor lands on whichever pump painted
487 // last. The fix: a focused paint records its screen cursor in Shared, and
488 // an unfocused paint's last act is to put the cursor back there. A
489 // focused tile with no core (the unit-test fixture) records nothing and
490 // restores nothing — the core is what knows the cursor.
491 const p = try std.posix.pipe2(.{ .NONBLOCK = true });
492 defer std.posix.close(p[0]);
493 defer std.posix.close(p[1]);
494 var shared = Shared{ .out_fd = p[1], .size = .{ .cols = 80, .rows = 24 }, .is_tty = true };
495 var tiles: [2]Tile = undefined;
496 tiles[0] = Tile{
497 .r = .{ .target = .{ .sock = "/s" }, .label = "a", .session = "a" },
498 .rect = .{ .top = 0, .left = 0, .rows = 12, .cols = 80 },
499 .shared = &shared,
500 .idx = 0,
501 .wake_r = -1,
502 .wake_w = -1,
503 };
504 tiles[1] = Tile{
505 .r = .{ .target = .{ .sock = "/s" }, .label = "b", .session = "b" },
506 .rect = .{ .top = 12, .left = 0, .rows = 12, .cols = 80 },
507 .shared = &shared,
508 .idx = 1,
509 .wake_r = -1,
510 .wake_w = -1,
511 };
512 shared.sel = 0;
513 shared.cursor = .{ .x = 3, .y = 5 };
514 var buf: [512]u8 = undefined;
515
516 // An UNFOCUSED paint (tile 1, core null) ends by restoring the shared
517 // cursor: hide, CUP to the focused tile's stored position, then show
518 // again so the cursor rests visible in the focused tile until its own
519 // next paint — without the trailing show the cursor stays hidden.
520 _ = wall_pump.tilePaintBegin(&tiles[1]);
521 wall_pump.tilePaintEnd(&tiles[1]);
522 try std.testing.expectEqualStrings("\x1b[?25l\x1b[6;4H\x1b[?25h", fixture.readAvail(p[0], &buf));
523
524 // A FOCUSED paint (tile 0, core null) records nothing and restores
525 // nothing: no core means the shared cursor is left untouched, and the
526 // pipe receives no new bytes.
527 _ = wall_pump.tilePaintBegin(&tiles[0]);
528 wall_pump.tilePaintEnd(&tiles[0]);
529 try std.testing.expectEqual(@as(usize, 0), fixture.readAvail(p[0], &buf).len);
530 }
src/tui/wall_test_wall.zig
Old New
@@ -0,0 +1,1156 @@
1 //! The wall itself: tiles, focus, births, endings and label bars.
2 const std = @import("std");
3 const proto = @import("protocol");
4 const client = @import("client");
5 const interact = @import("interact");
6 const fixture = @import("wall_test_harness.zig");
7 const wall_host = @import("wall_host.zig");
8 const wall_layout = @import("wall_layout.zig");
9 const wall_pump = @import("wall_pump.zig");
10 const wv = @import("wallview.zig");
11 const EndAction = wv.EndAction;
12 const EndKey = wv.EndKey;
13 const EndReason = wv.EndReason;
14 const Resolved = wall_host.Resolved;
15 const Shared = wv.Shared;
16 const State = wv.State;
17 const Tile = wv.Tile;
18
19 test "birthTile: a chord-born tile creates and offers no agent" {
20 const alloc = std.testing.allocator;
21 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
22 defer shared.tree.deinit();
23 try shared.tree.addFirst(0);
24 var tiles: [2]Tile = undefined;
25 tiles[0] = .{
26 .r = .{ .target = .{ .sock = "/tmp/a" }, .label = "--sock /tmp/a", .session = "" },
27 .rect = .{ .top = 0, .left = 0, .rows = 24, .cols = 80 },
28 .shared = &shared,
29 .idx = 0,
30 .wake_r = -1,
31 .wake_w = -1,
32 };
33 var present = [_]bool{ true, false };
34 var live: usize = 1;
35 const r: Resolved = .{ .target = .{ .sock = "/tmp/b" }, .label = "--sock /tmp/b#b", .session = "b" };
36 const at = wv.birthTile(alloc, &tiles, &present, &live, &shared, .{
37 .r = r,
38 .from = 0,
39 .place = .beside_focus,
40 .creates = true,
41 .born_from = 0,
42 }) orelse return error.TestUnexpectedResult;
43 defer std.posix.close(tiles[at].wake_r);
44 defer std.posix.close(tiles[at].wake_w);
45 try std.testing.expectEqual(@as(usize, 1), at);
46 try std.testing.expect(tiles[at].creates);
47 try std.testing.expect(!tiles[at].r.agent);
48 try std.testing.expectEqual(@as(?usize, 0), tiles[at].born_from);
49 try std.testing.expect(present[1]);
50 try std.testing.expectEqual(@as(usize, 2), live);
51 // Two tiles draw a bar, and the bar is set at birth so viewRows is
52 // right from the first attach.
53 try std.testing.expectEqual(@as(u16, 1), shared.label_rows);
54 }
55
56 test "the empty wall advertises the key the picker really answers to" {
57 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = true };
58 const hint = wv.emptyWallHint(&shared);
59 // The sentence names a chord; this is what stops it outliving the
60 // binding. `Ctrl-\ s` in the text, `\x1c s` through the filter.
61 try std.testing.expect(std.mem.indexOf(u8, hint, "Ctrl-\\ s") != null);
62 var f: interact.PrefixFilter = .{};
63 var keys = "\x1cs".*;
64 try std.testing.expectEqual(interact.PrefixFilter.Action.pick_open, f.feed(&keys).action);
65 }
66
67 test "emptyWallHint: a chord an empty wall refuses is said there, and said once" {
68 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = true };
69 const way_out = "Ctrl-\\ s to pick a host - Ctrl-\\ d to leave";
70 try std.testing.expectEqualStrings(way_out, wv.emptyWallHint(&shared));
71
72 wv.setNotice(&shared, "[no session to birth beside - Ctrl-\\ s picks a host]");
73 shared.paint_mu.lock();
74 defer shared.paint_mu.unlock();
75 // An empty wall has no pump, and a notice is painted by the pump that
76 // claims the terminal — so this line is the only place it can be said.
77 try std.testing.expectEqualStrings(
78 "[no session to birth beside - Ctrl-\\ s picks a host]",
79 wv.emptyWallHint(&shared),
80 );
81 // Once: every relayout repaints the empty line, and a sentence that
82 // outlived the keystroke that earned it would never leave the screen.
83 try std.testing.expectEqualStrings(way_out, wv.emptyWallHint(&shared));
84 }
85
86 test "awaitDetach: a pump that has already returned is not waited on" {
87 var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
88 defer arena.deinit();
89 var shared: Shared = undefined;
90 fixture.stoppedWall(arena.allocator(), &shared);
91 var t: Tile = .{
92 .r = .{ .target = .{ .sock = "/tmp/a" }, .label = "--sock /tmp/a", .session = "0" },
93 .rect = .{ .top = 0, .left = 0, .rows = 24, .cols = 80 },
94 .shared = &shared,
95 .idx = 0,
96 .wake_r = -1,
97 .wake_w = -1,
98 .alive = std.atomic.Value(bool).init(false),
99 };
100 // The pump is the only thread that sets `detach_ack`, and this one has
101 // returned: the wait can only ever run out its 400ms bound.
102 const began = std.time.milliTimestamp();
103 wv.awaitDetach(&t, &shared);
104 try std.testing.expect(std.time.milliTimestamp() - began < 100);
105 }
106
107 test "setNoticeIdle: a standing condition waits — a refusal the user just earned is not overwritten" {
108 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
109 var buf: [96]u8 = undefined;
110
111 // The poll re-derives `[+N not shown]` every second per host, and the
112 // slot is one: the earned sentence has to survive to a paint.
113 wv.setNotice(&shared, "[bad host: FlagLikeTarget]");
114 wv.setNoticeIdle(&shared, "[+2 not shown]");
115 try std.testing.expectEqualStrings("[bad host: FlagLikeTarget]", wv.takeNotice(&shared, &buf));
116
117 // Read, and the standing condition takes the slot it was waiting for.
118 wv.setNoticeIdle(&shared, "[+2 not shown]");
119 try std.testing.expectEqualStrings("[+2 not shown]", wv.takeNotice(&shared, &buf));
120 }
121
122 test "endKey: x on a tile that never came up closes the TILE — there is no session to end" {
123 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
124 var t = Tile{
125 .r = .{ .target = .{ .sock = "/tmp/x" }, .label = "x", .session = "0" },
126 .rect = .{ .top = 0, .left = 0, .rows = 4, .cols = 80 },
127 .shared = &shared,
128 .idx = 0,
129 .wake_r = -1,
130 .wake_w = -1,
131 };
132 t.alive.store(true, .release);
133
134 // Parked on its first dial: alive, so the old rule stored an ask that
135 // `dial` never reads — no effect and no sentence, and the session ended
136 // under the user if the box ever came back.
137 t.state = .connecting;
138 try std.testing.expectEqual(EndKey.drop, wv.endKey(&t, 0));
139
140 // Once it has been up, the DAEMON owns the ending, reconnect or not.
141 t.ever_up.store(true, .release);
142 t.state = .reconnecting;
143 try std.testing.expectEqual(EndKey{ .ask = .end }, wv.endKey(&t, 0));
144 t.state = .up;
145 try std.testing.expectEqual(EndKey{ .ask = .end }, wv.endKey(&t, 0));
146
147 // The armed second press forces; the client never decides that itself.
148 t.end_armed_until.store(1000, .release);
149 try std.testing.expectEqual(EndKey{ .ask = .end_force }, wv.endKey(&t, 999));
150 try std.testing.expectEqual(EndKey{ .ask = .end }, wv.endKey(&t, 1000));
151
152 // A pump that has ended has nothing to ask over.
153 t.alive.store(false, .release);
154 try std.testing.expectEqual(EndKey.none, wv.endKey(&t, 0));
155 }
156
157 test "x on a tile whose daemon refused arms a second press, and the window closes after 3 s" {
158 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
159 defer shared.tree.deinit();
160 var t: Tile = .{
161 .r = .{ .target = .{ .sock = "/tmp/a" }, .label = "--sock /tmp/a", .session = "work" },
162 .rect = .{ .top = 0, .left = 0, .rows = 24, .cols = 80 },
163 .shared = &shared,
164 .idx = 0,
165 .wake_r = -1,
166 .wake_w = -1,
167 };
168 // A first `x` is never a force: the daemon is the one that knows who
169 // else is attached, so the client asks before it insists.
170 try std.testing.expectEqual(client.SwitchIntent.end, wv.intentForEnd(&t, 1000));
171 wv.onEndReply(&t, .{ .accepted = false, .others = 2, .reason = "others attached" }, 1000);
172 try std.testing.expectEqual(@as(i64, 1000 + wv.end_arm_ms), t.end_armed_until.load(.acquire));
173 try std.testing.expectEqual(client.SwitchIntent.end_force, wv.intentForEnd(&t, 2500));
174
175 // The window SHUTS. Without this an `x` typed minutes later, on a
176 // session that has picked up watchers since, kills them without asking.
177 try std.testing.expectEqual(client.SwitchIntent.end, wv.intentForEnd(&t, 4001));
178
179 // An accepted end disarms rather than leaving the window standing: the
180 // reply for the tile that is going must not force the next one.
181 t.end_armed_until.store(9000, .release);
182 wv.onEndReply(&t, .{ .accepted = true, .others = 0, .reason = "" }, 5000);
183 try std.testing.expectEqual(@as(i64, 0), t.end_armed_until.load(.acquire));
184 }
185
186 test "n/p walk the wall's tiles: a hole is stepped over, and a wall of one has nowhere to go" {
187 // Three slots with the middle one vanished, because the walk `n` used
188 // to do was one daemon's session ring and the wall is every host's
189 // sessions — a fixture of two adjacent tiles cannot see the hole.
190 const three = [_]bool{ true, false, true };
191 try std.testing.expectEqual(@as(?usize, 2), wv.walkTiles(&three, 0, true));
192 try std.testing.expectEqual(@as(?usize, 0), wv.walkTiles(&three, 2, true));
193 try std.testing.expectEqual(@as(?usize, 2), wv.walkTiles(&three, 0, false));
194 try std.testing.expectEqual(@as(?usize, 0), wv.walkTiles(&three, 2, false));
195
196 // One tile is its own neighbour, which is not a move: a focus that
197 // "moves" to itself releases the terminal and re-claims it for nothing.
198 const one = [_]bool{ false, true, false };
199 try std.testing.expectEqual(@as(?usize, null), wv.walkTiles(&one, 1, true));
200 try std.testing.expectEqual(@as(?usize, null), wv.walkTiles(&one, 1, false));
201
202 const none = [_]bool{ false, false };
203 try std.testing.expectEqual(@as(?usize, null), wv.walkTiles(&none, 0, true));
204 }
205
206 test "birthTile: no room is null and the tree is left as it was" {
207 const alloc = std.testing.allocator;
208 // Four rows cannot hold two stacked tiles under a bar: `wallFloors`
209 // asks min_session_rows + 1 of each. The leaf count pins the undo —
210 // a birth that refused and kept its insert would leave the tree one
211 // leaf wider than the wall.
212 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 4 }, .is_tty = false };
213 defer shared.tree.deinit();
214 try shared.tree.addFirst(0);
215 var tiles: [2]Tile = undefined;
216 tiles[0] = .{
217 .r = .{ .target = .{ .sock = "/tmp/a" }, .label = "--sock /tmp/a", .session = "" },
218 .rect = .{ .top = 0, .left = 0, .rows = 4, .cols = 80 },
219 .shared = &shared,
220 .idx = 0,
221 .wake_r = -1,
222 .wake_w = -1,
223 };
224 var present = [_]bool{ true, false };
225 var live: usize = 1;
226 const r: Resolved = .{ .target = .{ .sock = "/tmp/b" }, .label = "--sock /tmp/b#b", .session = "b" };
227 try std.testing.expectEqual(@as(?usize, null), wv.birthTile(alloc, &tiles, &present, &live, &shared, .{
228 .r = r,
229 .from = 0,
230 .place = .beside_focus,
231 .creates = true,
232 .born_from = 0,
233 }));
234 try std.testing.expectEqual(@as(usize, 1), live);
235 try std.testing.expect(!present[1]);
236 try std.testing.expectEqual(@as(usize, 1), shared.tree.count());
237 }
238
239 test "birthTile: a beside wall admits more panes than rows/3" {
240 const alloc = std.testing.allocator;
241 // Nine panes side by side on 24x200: every one keeps all 24 rows and
242 // 8 rails plus 9 columns of 21 fit inside 200. A rows/n admission test
243 // refuses this at the 9th (24/9 = 2), capping ANY terminal at rows/3
244 // panes however wide it is. The tree owns the answer.
245 const n = 9;
246 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 200, .rows = 24 }, .is_tty = false };
247 defer shared.tree.deinit();
248 try shared.tree.addFirst(0);
249 var tiles: [n]Tile = undefined;
250 var present = [_]bool{false} ** n;
251 var live: usize = 0;
252 var i: u8 = 0;
253 while (i < n - 1) : (i += 1) {
254 if (i > 0) {
255 try shared.tree.insert(i - 1, i);
256 // `insert` wraps a bare root leaf in `.stacked`; a wide
257 // terminal's wall is cut the other way (`rootOrient`).
258 if (i == 1) shared.tree.setRootOrient(.beside);
259 }
260 tiles[i] = .{
261 .r = .{ .target = .{ .sock = "/tmp/a" }, .label = "--sock /tmp/a", .session = "" },
262 .rect = .{ .top = 0, .left = 0, .rows = 24, .cols = 21 },
263 .shared = &shared,
264 .idx = i,
265 .wake_r = -1,
266 .wake_w = -1,
267 };
268 present[i] = true;
269 live += 1;
270 }
271 const r: Resolved = .{ .target = .{ .sock = "/tmp/b" }, .label = "--sock /tmp/b#b", .session = "b" };
272 const at = wv.birthTile(alloc, &tiles, &present, &live, &shared, .{
273 .r = r,
274 .from = n - 2,
275 .place = .beside_focus,
276 .creates = true,
277 .born_from = n - 2,
278 // Borrowed, so the tile gets copies of its own: these are literals,
279 // and `Birth.borrowed = false` promises the slot's next reuse two
280 // slices it may free.
281 .borrowed = true,
282 }) orelse return error.TestUnexpectedResult;
283 defer std.posix.close(tiles[at].wake_r);
284 defer std.posix.close(tiles[at].wake_w);
285 defer alloc.free(tiles[at].r.session);
286 defer alloc.free(tiles[at].r.label);
287 try std.testing.expectEqual(@as(usize, n), shared.tree.count());
288 // Admitted AND habitable: every pane keeps the full height and clears
289 // the column floor, which is what makes the refusal wrong.
290 const flat = try shared.tree.flatten(alloc, 24, 200, wall_layout.wallFloors(n), null);
291 defer flat.deinit(alloc);
292 try std.testing.expectEqual(@as(usize, n), flat.placed.len);
293 for (flat.placed) |p| {
294 try std.testing.expectEqual(@as(u16, 24), p.rect.rows);
295 try std.testing.expect(p.rect.cols >= proto.min_session_cols);
296 }
297 }
298
299 test "birthTile: a vanished digit is taken back, and not before its pump returned" {
300 const alloc = std.testing.allocator;
301 // Five slots on a wall wide enough for all of them: the claim is which
302 // digit a birth lands on, so no refusal may decide it instead.
303 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 200, .rows = 24 }, .is_tty = false };
304 defer shared.tree.deinit();
305 try shared.tree.addFirst(0);
306 var tiles: [5]Tile = undefined;
307 var present = [_]bool{ true, false, false, false, false };
308 var live: usize = 1;
309 const target: client.Target = .{ .sock = "/tmp/a" };
310 tiles[0] = .{
311 .r = .{ .target = target, .label = "--sock /tmp/a#0", .session = "0" },
312 .rect = .{ .top = 0, .left = 0, .rows = 24, .cols = 200 },
313 .shared = &shared,
314 .idx = 0,
315 .wake_r = -1,
316 .wake_w = -1,
317 };
318 // Every other tile is born the way a host's poll births one, so the
319 // copies a reuse has to free are the real owned ones.
320 defer for (tiles[1..], present[1..]) |*t, p| {
321 if (!p) continue;
322 alloc.free(t.r.session);
323 alloc.free(t.r.label);
324 std.posix.close(t.wake_r);
325 std.posix.close(t.wake_w);
326 };
327 const born = struct {
328 fn at(a: std.mem.Allocator, ts: []Tile, ps: []bool, lv: *usize, sh: *Shared, tg: client.Target, name: []const u8, host: ?usize) ?usize {
329 return wv.birthTile(a, ts, ps, lv, sh, .{
330 .r = .{ .target = tg, .label = "", .session = name },
331 .from = 0,
332 .place = .beside_focus,
333 .creates = true,
334 .born_from = 0,
335 .host = host,
336 .borrowed = true,
337 });
338 }
339 }.at;
340
341 try std.testing.expectEqual(@as(?usize, 1), born(alloc, &tiles, &present, &live, &shared, target, "1", 3));
342 try std.testing.expectEqual(@as(?usize, 2), born(alloc, &tiles, &present, &live, &shared, target, "2", 3));
343
344 // The middle tile leaves, carrying a full set of dirt: every one of
345 // these is a field whose stale value would be a lie about the NEXT
346 // tile in the slot — a session that was never up, a list that never
347 // missed it, an `x` nobody pressed, a host that does not own it.
348 tiles[1].ever_up.store(true, .release);
349 tiles[1].missed_once = true;
350 tiles[1].end_armed_until.store(9000, .release);
351 const doorbell = tiles[1].wake_r;
352 wv.vanishTile(&tiles, &present, &shared, 1, null);
353
354 // Its pump has not returned, so the digit is not free yet: the birth
355 // appends rather than hand a live thread's `*Tile` to a new tile.
356 try std.testing.expectEqual(@as(?usize, 3), born(alloc, &tiles, &present, &live, &shared, target, "3", 3));
357
358 tiles[1].pump_done.store(true, .release);
359 try std.testing.expectEqual(@as(?usize, 1), born(alloc, &tiles, &present, &live, &shared, target, "1", null));
360 // Reuse, not growth: the wall is four tiles wide, not five.
361 try std.testing.expectEqual(@as(usize, 4), live);
362 try std.testing.expectEqual(@as(usize, 1), tiles[1].idx);
363 try std.testing.expect(!tiles[1].ever_up.load(.acquire));
364 try std.testing.expect(!tiles[1].missed_once);
365 try std.testing.expectEqual(@as(i64, 0), tiles[1].end_armed_until.load(.acquire));
366 try std.testing.expectEqual(@as(?usize, null), tiles[1].host);
367 try std.testing.expect(!tiles[1].pump_done.load(.acquire));
368 // The doorbell is the slot's, not the tile's: a fresh pipe per reuse
369 // would leak the fd pair the old one is never closed on.
370 try std.testing.expectEqual(doorbell, tiles[1].wake_r);
371 }
372
373 test "birthTile: the digit's next tile frees the copies an unborrowed birth handed over" {
374 const alloc = std.testing.allocator;
375 // `Birth.borrowed = false` hands the tile two slices outright, and the
376 // birth that takes its digit back is what frees them. The testing
377 // allocator is the whole oracle: a slice it never handed out aborts the
378 // free, and one nobody frees is reported leaked — which is what a
379 // caller passing a literal and a reuse that kept the old copies each
380 // look like.
381 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 200, .rows = 24 }, .is_tty = false };
382 defer shared.tree.deinit();
383 try shared.tree.addFirst(0);
384 var tiles: [3]Tile = undefined;
385 var present = [_]bool{ true, false, false };
386 var live: usize = 1;
387 const target: client.Target = .{ .sock = "/tmp/a" };
388 tiles[0] = .{
389 .r = .{ .target = target, .label = "--sock /tmp/a#0", .session = "0" },
390 .rect = .{ .top = 0, .left = 0, .rows = 24, .cols = 200 },
391 .shared = &shared,
392 .idx = 0,
393 .wake_r = -1,
394 .wake_w = -1,
395 };
396 // The chord's road, verbatim: the caller allocates, `birthTile` takes
397 // the slices as they are.
398 const handed = struct {
399 fn birth(
400 a: std.mem.Allocator,
401 ts: []Tile,
402 ps: []bool,
403 lv: *usize,
404 sh: *Shared,
405 tg: client.Target,
406 name: []const u8,
407 ) ?usize {
408 const session = a.dupe(u8, name) catch return null;
409 const label = wv.tileLabel(a, tg, session) catch {
410 a.free(session);
411 return null;
412 };
413 return wv.birthTile(a, ts, ps, lv, sh, .{
414 .r = .{ .target = tg, .label = label, .session = session },
415 .from = 0,
416 .place = .beside_focus,
417 .creates = true,
418 .born_from = 0,
419 });
420 }
421 }.birth;
422
423 const first = handed(alloc, &tiles, &present, &live, &shared, target, "1") orelse
424 return error.TestUnexpectedResult;
425 try std.testing.expectEqual(@as(usize, 1), first);
426 defer if (present[1]) {
427 alloc.free(tiles[1].r.session);
428 alloc.free(tiles[1].r.label);
429 std.posix.close(tiles[1].wake_r);
430 std.posix.close(tiles[1].wake_w);
431 };
432
433 wv.vanishTile(&tiles, &present, &shared, 1, null);
434 tiles[1].pump_done.store(true, .release);
435 // The same digit, so the free under test is one this birth really did.
436 try std.testing.expectEqual(
437 @as(?usize, 1),
438 handed(alloc, &tiles, &present, &live, &shared, target, "2"),
439 );
440 }
441
442 test "labelText: the state word survives truncation at every width" {
443 var buf: [256]u8 = undefined;
444 // A label longer than any bar, so truncation is what is under test and
445 // not the label's own length.
446 const long = "x" ** 400;
447
448 // The markers are the ones a bar actually carries: three bytes with the
449 // chord digit, four for tiles 10 and up. Every one of them comes out of
450 // the label's cut, so the marker width is part of what this pins — a
451 // two-byte marker exercises no bar that ships.
452
453 // Narrow: the label is cut, the bar fits the terminal, the state stays.
454 const narrow = wv.labelText(&buf, 30, "1> ", long, "up");
455 if (!std.mem.endsWith(u8, narrow, " [up]")) return error.OneDigitNarrowBarLostTheStateWord;
456 if (narrow.len > 30) return error.OneDigitNarrowBarOverranTheTerminal;
457
458 // The same width with the widest marker: the two extra bytes come off
459 // the label, never off the state.
460 const narrow2 = wv.labelText(&buf, 30, "12 ", long, "up");
461 if (!std.mem.endsWith(u8, narrow2, " [up]")) return error.TwoDigitNarrowBarLostTheStateWord;
462 if (narrow2.len > 30) return error.TwoDigitNarrowBarOverranTheTerminal;
463
464 // Wide: `cols` alone would have asked for more than `buf` holds, and the
465 // failed bufPrint used to hand back the buffer's undefined bytes — with
466 // the state word among what was lost.
467 const wide = wv.labelText(&buf, 300, "12> ", long, "reconnecting");
468 if (!std.mem.endsWith(u8, wide, " [reconnecting]")) return error.TwoDigitWideBarLostTheStateWord;
469 if (wide.len > buf.len) return error.TwoDigitWideBarOverranItsBuffer;
470
471 // Wider than `buf` with the longest status word a bar can carry: the
472 // bound that matters is `buf`'s, and it is not `cols`'.
473 const widest = wv.labelText(&buf, 400, "12 ", long, State.reconnecting.word());
474 if (!std.mem.endsWith(u8, widest, " [reconnecting]")) return error.TwoDigitWidestBarLostTheStateWord;
475 if (widest.len > buf.len) return error.TwoDigitWidestBarOverranItsBuffer;
476 }
477
478 test "showsSelf: the wall never tiles the session the walling shell is standing in" {
479 const sock = "/run/user/1000/muxd.sock";
480 const target: client.Target = .{ .sock = sock };
481
482 // The case: `mux --session 1` typed in session 0's shell. Session 0 is
483 // on the host's own list, and tiling it would paint that session's
484 // stripe into the grid it is reading.
485 try std.testing.expect(wv.showsSelf(target, "0", sock, "0"));
486 // The empty name is the wire's spelling of the default, and the daemon
487 // plants the resolved one.
488 try std.testing.expect(wv.showsSelf(target, "", sock, "0"));
489 try std.testing.expect(wv.showsSelf(target, "work", sock, "work"));
490
491 // Every other session of the same daemon is exactly what the wall is
492 // for — it must not drop those.
493 try std.testing.expect(!wv.showsSelf(target, "1", sock, "0"));
494 try std.testing.expect(!wv.showsSelf(target, "", sock, "1"));
495 // A different daemon's socket is a different session whatever it is
496 // called.
497 try std.testing.expect(!wv.showsSelf(target, "0", "/run/user/1000/other.sock", "0"));
498 // Not a unix socket: a quic:// or --via target cannot be the daemon
499 // whose shell planted these.
500 try std.testing.expect(!wv.showsSelf(.{ .via = "ssh host muxd proxy" }, "0", sock, "0"));
501
502 // Unset, and the emptied spelling of unset that the self-attach
503 // refusal names as the way to override it.
504 try std.testing.expect(!wv.showsSelf(target, "0", null, "0"));
505 try std.testing.expect(!wv.showsSelf(target, "0", sock, null));
506 try std.testing.expect(!wv.showsSelf(target, "0", "", "0"));
507 try std.testing.expect(!wv.showsSelf(target, "0", sock, ""));
508 }
509
510 test "sendKeys: a chunk that does not fit is dropped whole, and says so" {
511 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
512 var t = Tile{
513 .r = .{ .target = .{ .sock = "/tmp/x" }, .label = "x", .session = "" },
514 .rect = .{ .top = 0, .left = 0, .rows = 4, .cols = 80 },
515 .shared = &shared,
516 .idx = 0,
517 // -1 both ends: `ring` writes to the doorbell and ignores the
518 // failure, which is exactly what it promises to do.
519 .wake_r = -1,
520 .wake_w = -1,
521 };
522
523 const head = "abc";
524 wv.sendKeys(&t, head);
525 try std.testing.expectEqual(@as(usize, 3), t.in_len);
526 try std.testing.expect(!t.in_dropped.load(.acquire));
527
528 // One byte more than the room left. The old code copied what fit.
529 const too_big = [_]u8{'z'} ** (wv.mailbox_max - 2);
530 wv.sendKeys(&t, &too_big);
531 try std.testing.expectEqual(@as(usize, 3), t.in_len);
532 try std.testing.expect(t.in_dropped.load(.acquire));
533
534 // ...and the mailbox still holds exactly what was typed before it, so
535 // the next chunk cannot splice onto a half-delivered one.
536 wv.sendKeys(&t, "def");
537 var out: [wv.mailbox_max]u8 = undefined;
538 try std.testing.expectEqualStrings("abcdef", wall_pump.takeKeys(&t, &out));
539
540 // Exactly filling it is not overflow — the bound is `>`, not `>=`.
541 const exact = [_]u8{'q'} ** wv.mailbox_max;
542 t.in_dropped.store(false, .release);
543 wv.sendKeys(&t, &exact);
544 try std.testing.expectEqual(@as(usize, wv.mailbox_max), t.in_len);
545 try std.testing.expect(!t.in_dropped.load(.acquire));
546 }
547
548 test "labelText: a dropped chunk is narrated beside the state that caused it" {
549 // The bar's own format, asserted through the same truncation path the
550 // states go through, because "reconnecting, input dropped" is now the
551 // longest thing a bar can say and `buf`'s bound is what it tests.
552 var buf: [256]u8 = undefined;
553 const long = "x" ** 400;
554 const said = wv.labelText(&buf, 300, "> ", long, "reconnecting, input dropped");
555 try std.testing.expect(std.mem.endsWith(u8, said, " [reconnecting, input dropped]"));
556 try std.testing.expect(said.len <= buf.len);
557 }
558
559 test "noticeText: the banner says what the exit line says, whichever sentence it is" {
560 var buf: [128]u8 = undefined;
561 // Both sentences a vanish can carry. A notice that names a cause the
562 // stderr line contradicts is worse than either alone.
563 try std.testing.expectEqualStrings(
564 "[cannot create a new session (daemon full?)]",
565 wv.noticeText(&buf, "mux: cannot create a new session (daemon full?)"),
566 );
567 try std.testing.expectEqualStrings(
568 "[cannot create a new session (daemon unreachable)]",
569 wv.noticeText(&buf, "mux: cannot create a new session (daemon unreachable)"),
570 );
571 // A sentence that never wore the prefix keeps all of its words.
572 try std.testing.expectEqualStrings("[detached]", wv.noticeText(&buf, "detached"));
573 }
574
575 test "endAction: on a terminal the last tile's exit leaves an empty wall, not an exit" {
576 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = true };
577 var tiles: [3]Tile = undefined;
578 const one = [_]bool{ true, false, false };
579
580 // `x` ends a session, never the program: the wall stays up with the
581 // picker over it, and the machines are still there to birth on. There
582 // is no tile left to hand the focus to, which is what `back` says.
583 fixture.endBench(&tiles, &shared, 0, .exited, 7, null);
584 try std.testing.expectEqual(
585 EndAction{ .vanish = .{ .back = null, .msg = null } },
586 wv.endAction(&tiles, &one, 3, 0, true, true),
587 );
588
589 // The piped half is unchanged and load-bearing: a wall of one with no
590 // keyboard is what a script runs, and it exits with the shell's code.
591 fixture.endBench(&tiles, &shared, 0, .exited, 7, null);
592 try std.testing.expectEqual(
593 EndAction{ .finish = .{ .code = 7, .msg = null } },
594 wv.endAction(&tiles, &one, 3, 0, true, false),
595 );
596 }
597
598 test "endAction: a birth the picker made into a standing wall is refused ON it, not out of mux" {
599 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = true };
600 var tiles: [3]Tile = undefined;
601 const one = [_]bool{ true, false, false };
602
603 // The headline path: the wall's last session ends, the picker opens
604 // itself, Enter lands on a daemon that is full. There was no anchor to
605 // hand the focus back to, so `born_from` is null — and the ENTRY tile
606 // carries null for the opposite reason, which is why the flag exists.
607 fixture.endBench(&tiles, &shared, 0, .refused, 0, null);
608 tiles[0].keeps_wall = true;
609 try std.testing.expectEqual(
610 EndAction{ .vanish = .{ .back = null, .msg = "mux: cannot create a new session (daemon full?)" } },
611 wv.endAction(&tiles, &one, 3, 0, true, true),
612 );
613
614 // A box that never answered: the picker advertises `unreachable` rows
615 // as a place to press Enter, so this is a designed path, not a fault.
616 fixture.endBench(&tiles, &shared, 0, .lost, 0, null);
617 tiles[0].keeps_wall = true;
618 try std.testing.expectEqual(
619 EndAction{ .vanish = .{ .back = null, .msg = "mux: cannot create a new session (daemon unreachable)" } },
620 wv.endAction(&tiles, &one, 3, 0, true, true),
621 );
622
623 // Nothing refused anything here, and the wall still stands.
624 fixture.endBench(&tiles, &shared, 0, .no_thread, 0, null);
625 tiles[0].keeps_wall = true;
626 try std.testing.expectEqual(
627 EndAction{ .vanish = .{ .back = null, .msg = "mux: could not start a thread for this session" } },
628 wv.endAction(&tiles, &one, 3, 0, true, true),
629 );
630
631 // The entry tile of `mux TARGET` is the wall, and its refusal is mux's:
632 // a script reads the code.
633 fixture.endBench(&tiles, &shared, 0, .refused, 0, null);
634 try std.testing.expectEqual(
635 EndAction{ .finish = .{ .code = 1, .msg = "mux: attach refused or no state received (session full?)" } },
636 wv.endAction(&tiles, &one, 3, 0, true, true),
637 );
638
639 // ...and a piped `mux` has no wall to leave standing, flag or no flag.
640 fixture.endBench(&tiles, &shared, 0, .refused, 0, null);
641 tiles[0].keeps_wall = true;
642 try std.testing.expectEqual(
643 EndAction{ .finish = .{ .code = 1, .msg = "mux: attach refused or no state received (session full?)" } },
644 wv.endAction(&tiles, &one, 3, 0, true, false),
645 );
646 }
647
648 test "endAction: the only tile's ending is mux's, and so is any ending nobody can leave" {
649 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
650 var tiles: [3]Tile = undefined;
651 const one = [_]bool{ true, false, false };
652 const two = [_]bool{ true, true, false };
653
654 // One tile, shell exited: the code is mux's, which is what keeps `mux`
655 // something you can put in a script.
656 fixture.endBench(&tiles, &shared, 0, .exited, 7, null);
657 try std.testing.expectEqual(
658 EndAction{ .finish = .{ .code = 7, .msg = null } },
659 wv.endAction(&tiles, &one, 3, 0, true, false),
660 );
661
662 // Two tiles: the exit vanishes — a dead stripe for a cleanly exited
663 // shell is noise — and the wall re-cuts onto the survivor.
664 fixture.endBench(&tiles, &shared, 0, .exited, 7, null);
665 try std.testing.expectEqual(
666 EndAction{ .vanish = .{ .back = 1, .msg = null } },
667 wv.endAction(&tiles, &two, 3, 0, true, false),
668 );
669
670 // ...but not once stdin has closed. A piped `mux` with no keyboard
671 // waits in poll forever with the code in hand — reproduced at RC=124
672 // before this argument existed, which is the whole reason the rule is
673 // stated in one pure function and not at the read that happens to
674 // notice EOF.
675 fixture.endBench(&tiles, &shared, 0, .exited, 7, null);
676 try std.testing.expectEqual(
677 EndAction{ .finish = .{ .code = 7, .msg = null } },
678 wv.endAction(&tiles, &two, 3, 0, false, false),
679 );
680
681 // A tile the user already forgot is not company: `present`, never the
682 // wall's length, is what "somewhere to go" counts.
683 const one_left = [_]bool{ true, false, false };
684 fixture.endBench(&tiles, &shared, 0, .exited, 3, null);
685 try std.testing.expectEqual(
686 EndAction{ .finish = .{ .code = 3, .msg = null } },
687 wv.endAction(&tiles, &one_left, 3, 0, true, false),
688 );
689 }
690
691 test "endAction: a refusal a chord earned goes back where the chord was typed" {
692 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
693 var tiles: [3]Tile = undefined;
694 const two = [_]bool{ true, true, false };
695
696 // The plain client's `.refused` recovery: the daemon would not create
697 // the session, and the chord-born tile leaves with no trace — the
698 // sentence is the record a script reads, the survivor the screen.
699 fixture.endBench(&tiles, &shared, 1, .refused, 1, 0);
700 try std.testing.expectEqual(
701 EndAction{ .vanish = .{ .back = 0, .msg = "mux: cannot create a new session (daemon full?)" } },
702 wv.endAction(&tiles, &two, 3, 1, true, false),
703 );
704
705 // It outranks the closed stdin, because vanishing lands on a live
706 // SESSION rather than on nothing: whatever ends that one ends the run.
707 fixture.endBench(&tiles, &shared, 1, .refused, 1, 0);
708 try std.testing.expectEqual(
709 EndAction{ .vanish = .{ .back = 0, .msg = "mux: cannot create a new session (daemon full?)" } },
710 wv.endAction(&tiles, &two, 3, 1, false, false),
711 );
712
713 // A daemon that never answered is not a daemon that answered and said
714 // no. `x` on the last LOCAL session ends the local daemon, so a wall
715 // whose own machine has gone is exactly how this arrives — and "full?"
716 // would send the user hunting for sessions that are not there.
717 fixture.endBench(&tiles, &shared, 1, .lost, 1, 0);
718 try std.testing.expectEqual(
719 EndAction{ .vanish = .{ .back = 0, .msg = "mux: cannot create a new session (daemon unreachable)" } },
720 wv.endAction(&tiles, &two, 3, 1, true, false),
721 );
722
723 // ...but a chord-born tile that HAD its session and lost the link later
724 // is not a failed creation at all: it keeps its rect and narrates, like
725 // any other lost tile.
726 fixture.endBench(&tiles, &shared, 1, .lost, 1, 0);
727 tiles[1].ever_up.store(true, .release);
728 try std.testing.expectEqual(
729 EndAction{ .refocus = 0 },
730 wv.endAction(&tiles, &two, 3, 1, true, false),
731 );
732
733 // A refusal with nowhere to fall back to is an ending like any other,
734 // and says the thing the plain client said.
735 fixture.endBench(&tiles, &shared, 0, .refused, 1, null);
736 const one = [_]bool{ true, false, false };
737 const out = wv.endAction(&tiles, &one, 3, 0, true, false);
738 try std.testing.expectEqual(@as(u8, 1), out.finish.code);
739 try std.testing.expectEqualStrings(
740 "mux: attach refused or no state received (session full?)",
741 out.finish.msg.?,
742 );
743
744 // ...and so is one whose fall-back tile the user forgot meanwhile.
745 fixture.endBench(&tiles, &shared, 1, .refused, 1, 0);
746 const gone_back = [_]bool{ false, true, false };
747 try std.testing.expectEqual(
748 @as(u8, 1),
749 wv.endAction(&tiles, &gone_back, 3, 1, true, false).finish.code,
750 );
751 }
752
753 test "endAction: an exited session's tile vanishes; a lost one narrates" {
754 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
755 var tiles: [3]Tile = undefined;
756 const two = [_]bool{ true, true, false };
757
758 // A clean exit is noise once it is over: the tile leaves, the wall
759 // re-cuts, and nothing is said.
760 fixture.endBench(&tiles, &shared, 0, .exited, 7, null);
761 try std.testing.expectEqual(
762 EndAction{ .vanish = .{ .back = 1, .msg = null } },
763 wv.endAction(&tiles, &two, 3, 0, true, false),
764 );
765
766 // A lost link can heal: the tile keeps its rect and its bar, and the
767 // focus steps to the next present tile rather than the tile leaving.
768 fixture.endBench(&tiles, &shared, 0, .lost, 1, null);
769 try std.testing.expectEqual(
770 EndAction{ .refocus = 1 },
771 wv.endAction(&tiles, &two, 3, 0, true, false),
772 );
773
774 // The last present tile exiting is still mux's own ending, with the
775 // shell's code — `mux` in a script is the thing that ran the shell.
776 fixture.endBench(&tiles, &shared, 0, .exited, 3, null);
777 const one = [_]bool{ true, false, false };
778 try std.testing.expectEqual(
779 EndAction{ .finish = .{ .code = 3, .msg = null } },
780 wv.endAction(&tiles, &one, 3, 0, true, false),
781 );
782 }
783
784 test "endAction: a vanishing tile hands focus to a live neighbour, not a corpse" {
785 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
786 var tiles: [3]Tile = undefined;
787 const two = [_]bool{ true, true, false };
788
789 // Tile 0 (focused) has just exited — its pump is dead. Tile 1 is
790 // present but dead (pump ended, narrating a lost link). With no live
791 // neighbour, the back-target falls back to the dead tile (the wall
792 // still shows it), but with a live third tile, focus lands there.
793 fixture.endBench(&tiles, &shared, 0, .exited, 7, null);
794 tiles[0].alive.store(false, .release);
795 tiles[1].alive.store(false, .release);
796 tiles[1].end.store(@intFromEnum(EndReason.lost), .release);
797 tiles[1].end_seen = true;
798 // Two tiles, both dead: fallback to the present-only step.
799 const out_dead = wv.endAction(&tiles, &two, 3, 0, true, false);
800 try std.testing.expect(out_dead == .vanish);
801 try std.testing.expectEqual(@as(usize, 1), out_dead.vanish.back);
802
803 // Three tiles: 0 dead ending, 1 dead narrating, 2 alive. Focus must
804 // land on tile 2, the live neighbour — never the dead tile 1.
805 const three = [_]bool{ true, true, true };
806 fixture.endBench(&tiles, &shared, 0, .exited, 7, null);
807 tiles[0].alive.store(false, .release);
808 tiles[1].alive.store(false, .release);
809 tiles[1].end.store(@intFromEnum(EndReason.lost), .release);
810 tiles[1].end_seen = true;
811 const out_live = wv.endAction(&tiles, &three, 3, 0, true, false);
812 try std.testing.expect(out_live == .vanish);
813 try std.testing.expectEqual(@as(usize, 2), out_live.vanish.back);
814 }
815
816 test "closeNotice: a sentence the popup left behind is given a tile to land on" {
817 const alloc = std.testing.allocator;
818 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
819 const tiles = try alloc.alloc(Tile, 2);
820 defer alloc.free(tiles);
821 fixture.noticeBench(&shared, tiles);
822 const present = [_]bool{ true, true };
823 // Focus on tile 1, and the forgotten host owned NEITHER tile: the focus
824 // does not move on its own here, which is the whole of the finding.
825 shared.sel = 1;
826 wv.setNotice(&shared, "[hosts file not updated: read-only file system]");
827
828 wv.closeNotice(tiles, &present, &shared);
829
830 // Armed, not merely rung: a pump paints the notice on a CLAIM, and a
831 // doorbell alone gives it nothing to claim for.
832 if (!tiles[1].claim_pending.load(.acquire)) return error.NoticeHasNoClaimToRideOn;
833 // Still there for the pump to take on that claim, whole: a keyboard
834 // that consumed it here would paint nothing and lose the sentence.
835 var buf: [96]u8 = undefined;
836 try std.testing.expectEqualStrings(
837 "[hosts file not updated: read-only file system]",
838 wv.takeNotice(&shared, &buf),
839 );
840
841 // The control: with nothing to say, the close is the bare retry it was,
842 // and no tile is handed a claim it did not earn.
843 fixture.noticeBench(&shared, tiles);
844 wv.closeNotice(tiles, &present, &shared);
845 try std.testing.expect(!tiles[1].claim_pending.load(.acquire));
846 }
847
848 test "bare keys reach the focused tile; the prefix does not" {
849 var f: interact.PrefixFilter = .{};
850 // Bare keys are forwarded to the focused tile.
851 var keys: [3]u8 = .{ 'a', 'b', 'c' };
852 const a = f.feed(&keys);
853 try std.testing.expectEqualStrings("abc", a.forward);
854 try std.testing.expectEqual(interact.PrefixFilter.Action.none, a.action);
855 // A chord consumes its key and produces no forward bytes.
856 var chord: [2]u8 = .{ 0x1c, 'n' };
857 const b = f.feed(&chord);
858 try std.testing.expectEqual(@as(usize, 0), b.forward.len);
859 try std.testing.expectEqual(interact.PrefixFilter.Action.next_session, b.action);
860 }
861
862 test "focus follows a click: rectHit picks the tile under a row and column" {
863 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
864 shared.label_rows = 1;
865 var tiles: [2]Tile = undefined;
866 tiles[0] = Tile{
867 .r = .{ .target = .{ .sock = "/tmp/x" }, .label = "x", .session = "" },
868 .rect = .{ .top = 0, .left = 0, .rows = 12, .cols = 80 },
869 .shared = &shared,
870 .idx = 0,
871 .wake_r = -1,
872 .wake_w = -1,
873 };
874 tiles[1] = Tile{
875 .r = .{ .target = .{ .sock = "/tmp/y" }, .label = "y", .session = "" },
876 .rect = .{ .top = 12, .left = 0, .rows = 12, .cols = 80 },
877 .shared = &shared,
878 .idx = 1,
879 .wake_r = -1,
880 .wake_w = -1,
881 };
882 const present = [_]bool{ true, true };
883 // A click in row 3 hits tile 0.
884 try std.testing.expectEqual(@as(?usize, 0), wv.rectHit(&tiles, &present, &shared, 3, 0));
885 // A click in row 15 hits tile 1.
886 try std.testing.expectEqual(@as(?usize, 1), wv.rectHit(&tiles, &present, &shared, 15, 0));
887
888 // A beside pair: same rows, different columns. A click in the left
889 // half hits tile 0, the right half hits tile 1, and a rail column
890 // between them hits neither.
891 tiles[0].rect = .{ .top = 0, .left = 0, .rows = 24, .cols = 39 };
892 tiles[1].rect = .{ .top = 0, .left = 41, .rows = 24, .cols = 39 };
893 try std.testing.expectEqual(@as(?usize, 0), wv.rectHit(&tiles, &present, &shared, 5, 10));
894 try std.testing.expectEqual(@as(?usize, 1), wv.rectHit(&tiles, &present, &shared, 5, 50));
895 try std.testing.expectEqual(@as(?usize, null), wv.rectHit(&tiles, &present, &shared, 5, 40));
896 }
897
898 test "setFocus writes the outgoing tile's release before the store that lets the next pump claim" {
899 // The keyboard thread's half of the terminal handover, and the order is
900 // load-bearing: two pumps are two threads, and A's release and B's claim
901 // race if the release is not on the wire first. The outgoing pump writes
902 // nothing (already_written), so if this stops emitting, nothing does —
903 // and the wall reports clicks into whatever shell the user lands in.
904 // is_tty is true because this leg is about what a focus move WRITES, and
905 // the gate that suppresses those writes on a pipe is exactly what it
906 // must not be testing.
907 const p = try std.posix.pipe2(.{ .NONBLOCK = true });
908 defer std.posix.close(p[0]);
909 defer std.posix.close(p[1]);
910 var shared = Shared{ .out_fd = p[1], .size = .{ .cols = 80, .rows = 24 }, .is_tty = true };
911 var tiles: [2]Tile = undefined;
912 tiles[0] = Tile{
913 .r = .{ .target = .{ .sock = "/s" }, .label = "a", .session = "a" },
914 .rect = .{ .top = 0, .left = 0, .rows = 12, .cols = 80 },
915 .shared = &shared,
916 .idx = 0,
917 .wake_r = -1,
918 .wake_w = -1,
919 };
920 tiles[1] = Tile{
921 .r = .{ .target = .{ .sock = "/s" }, .label = "b", .session = "b" },
922 .rect = .{ .top = 12, .left = 0, .rows = 12, .cols = 80 },
923 .shared = &shared,
924 .idx = 1,
925 .wake_r = -1,
926 // The claim doorbell lands on the SAME pipe as the release: the
927 // write order on one fd is the only order a test can read, and the
928 // release must come first.
929 .wake_w = p[1],
930 };
931 shared.sel = 0;
932 var buf: [512]u8 = undefined;
933
934 // A move to the SAME tile has no outgoing session to take off, so it
935 // writes no release — a release here would unset modes the user's own
936 // terminal may have arrived with.
937 wv.setFocus(&tiles, &shared, 0);
938 try std.testing.expect(std.mem.indexOf(u8, fixture.readAvail(p[0], &buf), interact.session_release) == null);
939
940 // A -> B: the release is written under paint_mu, before sel is stored
941 // and before B is doorbelled to claim. On the one pipe both share that
942 // shows up as the release preceding the claim's wake byte.
943 wv.setFocus(&tiles, &shared, 1);
944 const out = fixture.readAvail(p[0], &buf);
945 const rel = std.mem.indexOf(u8, out, interact.session_release) orelse
946 return error.NoReleaseOnFocusMove;
947 const bell = std.mem.indexOfScalar(u8, out, 0) orelse
948 return error.NoClaimDoorbell;
949 try std.testing.expect(rel < bell);
950 // The outgoing pump is told to release, the incoming one to claim.
951 try std.testing.expect(tiles[0].release_pending.load(.acquire));
952 try std.testing.expect(tiles[1].claim_pending.load(.acquire));
953 try std.testing.expectEqual(@as(usize, 1), shared.sel);
954 }
955
956 test "a focus move drops what the wall's input filters are holding" {
957 // The input-filter state that mattered — the drag — moved off the
958 // wall's keyboard loop and onto each tile's Core. setFocus drops it the
959 // same way relayout does: a repaint_gen bump every pump clears its
960 // core.drag on. The wall-level prefix is deliberately KEPT, because a
961 // chord is a wall command now and not a tile's; what stopped existing on
962 // a focus move is the screen the drag was drawn on, not a half-typed
963 // chord about it.
964 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
965 var tiles: [2]Tile = undefined;
966 tiles[0] = Tile{
967 .r = .{ .target = .{ .sock = "/s" }, .label = "a", .session = "a" },
968 .rect = .{ .top = 0, .left = 0, .rows = 12, .cols = 80 },
969 .shared = &shared,
970 .idx = 0,
971 .wake_r = -1,
972 .wake_w = -1,
973 };
974 tiles[1] = Tile{
975 .r = .{ .target = .{ .sock = "/s" }, .label = "b", .session = "b" },
976 .rect = .{ .top = 12, .left = 0, .rows = 12, .cols = 80 },
977 .shared = &shared,
978 .idx = 1,
979 .wake_r = -1,
980 .wake_w = -1,
981 };
982 shared.sel = 0;
983 const before = shared.repaint_gen.load(.acquire);
984 wv.setFocus(&tiles, &shared, 1);
985 try std.testing.expect(shared.repaint_gen.load(.acquire) > before);
986 }
987
988 test "setFocus: the marker moves between two tiles with no pump to move it" {
989 // A refused attach and an exited session both leave a tile
990 // whose pump has ended: repaint_gen and the doorbell reach nobody, so
991 // the bar keeps whatever marker it was born with. Two of them and
992 // Ctrl-\ 1 / Ctrl-\ 2 change nothing on screen; one of them beside a
993 // live tile shows two `>`. The keyboard is the only thread left that
994 // can paint these, which is what this pins.
995 const p = try std.posix.pipe2(.{ .NONBLOCK = true });
996 defer std.posix.close(p[0]);
997 defer std.posix.close(p[1]);
998 var shared = Shared{ .out_fd = p[1], .size = .{ .cols = 80, .rows = 24 }, .is_tty = true };
999 shared.label_rows = 1; // two tiles draw a bar
1000 var tiles: [3]Tile = undefined;
1001 tiles[0] = Tile{
1002 .r = .{ .target = .{ .sock = "/s" }, .label = "one", .session = "a" },
1003 .rect = .{ .top = 0, .left = 0, .rows = 12, .cols = 80 },
1004 .shared = &shared,
1005 .idx = 0,
1006 .wake_r = -1,
1007 .wake_w = -1,
1008 .state = .refused,
1009 .alive = std.atomic.Value(bool).init(false),
1010 };
1011 tiles[1] = Tile{
1012 .r = .{ .target = .{ .sock = "/s" }, .label = "two", .session = "b" },
1013 .rect = .{ .top = 12, .left = 0, .rows = 12, .cols = 80 },
1014 .shared = &shared,
1015 .idx = 1,
1016 .wake_r = -1,
1017 .wake_w = -1,
1018 .state = .refused,
1019 .alive = std.atomic.Value(bool).init(false),
1020 };
1021 // Forgotten, not merely dead: `x` clears `present` and sets `gone`
1022 // together, and a bar painted into a rect the layout no longer owns
1023 // writes over a neighbour.
1024 tiles[2] = Tile{
1025 .r = .{ .target = .{ .sock = "/s" }, .label = "ghost", .session = "c" },
1026 .rect = .{ .top = 0, .left = 0, .rows = 12, .cols = 80 },
1027 .shared = &shared,
1028 .idx = 2,
1029 .wake_r = -1,
1030 .wake_w = -1,
1031 .state = .exited,
1032 .alive = std.atomic.Value(bool).init(false),
1033 .gone = std.atomic.Value(bool).init(true),
1034 };
1035 shared.sel = 0;
1036
1037 wv.setFocus(&tiles, &shared, 1);
1038
1039 var buf: [2048]u8 = undefined;
1040 const out = fixture.readAvail(p[0], &buf);
1041 // The bar is one contiguous write (`paintLabelLocked`), so the cursor
1042 // move and the marker are adjacent bytes: matching them together is
1043 // what says the marker landed in THIS tile's bar and not a neighbour's.
1044 if (std.mem.indexOf(u8, out, "\x1b[13;1H\x1b[7m 2> two") == null)
1045 return error.DeadTileNeverGainsTheMarker;
1046 if (std.mem.indexOf(u8, out, "\x1b[1;1H\x1b[7m 1 one") == null)
1047 return error.DeadTileNeverDropsTheMarker;
1048 if (std.mem.indexOf(u8, out, "ghost") != null)
1049 return error.ForgottenTilePaintedItsBar;
1050 }
1051
1052 test "the bar's digit is the tile's chord: idx + 1, and a two-digit tile still says where it is" {
1053 // Ctrl-\ 1-9 focuses by POSITION (tiles[idx - 1]) while the bar named the
1054 // session (#0, #2...): two series that stop lining up the moment a tile is
1055 // forgotten or a session dies. The digit on the bar is the chord's, so the
1056 // two cannot diverge.
1057 const p = try std.posix.pipe2(.{ .NONBLOCK = true });
1058 defer std.posix.close(p[0]);
1059 defer std.posix.close(p[1]);
1060 var shared = Shared{ .out_fd = p[1], .size = .{ .cols = 80, .rows = 24 }, .is_tty = true };
1061 shared.label_rows = 1;
1062 shared.sel = 0;
1063 var tiles: [3]Tile = undefined;
1064 const labels = [_][]const u8{ "one", "two", "twelve" };
1065 // idx 11 with no tiles 3..10: the digit is the tile's own idx, never a
1066 // count of what is on screen. Past 9 the chord cannot reach it, but the
1067 // number still says where it sits — the chord's reach is the chord's
1068 // limit, not the bar's.
1069 const idxs = [_]usize{ 0, 1, 11 };
1070 for (&tiles, labels, idxs, 0..) |*t, label, idx, row| {
1071 t.* = Tile{
1072 .r = .{ .target = .{ .sock = "/s" }, .label = label, .session = "a" },
1073 .rect = .{ .top = @intCast(row * 8), .left = 0, .rows = 8, .cols = 80 },
1074 .shared = &shared,
1075 .idx = idx,
1076 .wake_r = -1,
1077 .wake_w = -1,
1078 .state = .up,
1079 .alive = std.atomic.Value(bool).init(false),
1080 };
1081 wv.paintLabelLocked(t);
1082 }
1083
1084 var buf: [2048]u8 = undefined;
1085 const out = fixture.readAvail(p[0], &buf);
1086 // Cursor move and marker together, as in the setFocus pin: that is what
1087 // says the digit landed in THIS tile's bar and not a neighbour's.
1088 if (std.mem.indexOf(u8, out, "\x1b[1;1H\x1b[7m 1> one") == null)
1089 return error.FocusedBarLostItsChordDigit;
1090 if (std.mem.indexOf(u8, out, "\x1b[9;1H\x1b[7m 2 two") == null)
1091 return error.UnfocusedBarLostItsChordDigit;
1092 if (std.mem.indexOf(u8, out, "\x1b[17;1H\x1b[7m 12 twelve") == null)
1093 return error.TwoDigitTileLostItsNumber;
1094 }
1095
1096 test "a vanished tile is stepped over" {
1097 // A tile's end and a poll that no longer lists its session both refocus
1098 // through stepPresent, and a click's rectHit and the Ctrl-\ l and 1-9
1099 // chords check the same present array a vanish clears. Tiles are never
1100 // compacted — pumps hold pointers into the array — so a vanished tile is
1101 // a hole focus steps over, not a gap it fills. gone and present clear
1102 // together in `vanishTile`, so stepping on present is stepping past
1103 // gone.
1104 const mid = [_]bool{ true, false, true };
1105 try std.testing.expectEqual(@as(?usize, 2), wv.stepPresent(&mid, 0, true));
1106 try std.testing.expectEqual(@as(?usize, 0), wv.stepPresent(&mid, 2, true));
1107 try std.testing.expectEqual(@as(?usize, 0), wv.stepPresent(&mid, 2, false));
1108 try std.testing.expectEqual(@as(?usize, 2), wv.stepPresent(&mid, 0, false));
1109 // Standing on the hole (the moment after x, before the selection has
1110 // moved) still steps to a real tile.
1111 try std.testing.expectEqual(@as(?usize, 2), wv.stepPresent(&mid, 1, true));
1112 try std.testing.expectEqual(@as(?usize, 0), wv.stepPresent(&mid, 1, false));
1113
1114 // The last tile gone: nothing is a selection any more, and every
1115 // motion says so rather than picking a tile that is gone.
1116 const none = [_]bool{ false, false };
1117 try std.testing.expectEqual(@as(?usize, null), wv.stepPresent(&none, 0, true));
1118 try std.testing.expectEqual(@as(?usize, null), wv.stepPresent(&none, 0, false));
1119
1120 // One survivor is its own answer, exactly as a one-tile wall is.
1121 const one = [_]bool{ false, true, false };
1122 try std.testing.expectEqual(@as(?usize, 1), wv.stepPresent(&one, 1, true));
1123 try std.testing.expectEqual(@as(?usize, 1), wv.stepPresent(&one, 0, false));
1124 }
1125
1126 test "tileBanner: a prompt wider than its pane shows the tail inside the pane" {
1127 // A tile that painted a whole long spelling would run across the rail
1128 // into its neighbour, so the banner is bounded by the tile's rect and
1129 // not by the terminal. The tail is what is kept: the cursor end of the
1130 // line the user is still typing.
1131 const pipe = try std.posix.pipe();
1132 defer std.posix.close(pipe[0]);
1133 var shared = Shared{ .out_fd = pipe[1], .size = .{ .cols = 80, .rows = 24 }, .is_tty = true };
1134 shared.label_rows = 1;
1135 var t = Tile{
1136 .r = .{ .target = .{ .sock = "/tmp/x" }, .label = "x", .session = "" },
1137 .rect = .{ .top = 2, .left = 5, .rows = 5, .cols = 10 },
1138 .shared = &shared,
1139 .idx = 0,
1140 .wake_r = -1,
1141 .wake_w = -1,
1142 };
1143 const text = ": --sock /tmp/very/long/paths_";
1144 try std.testing.expectEqual(@as(usize, 30), text.len);
1145 wv.tileBanner(&t, text);
1146 std.posix.close(pipe[1]);
1147
1148 var out: [256]u8 = undefined;
1149 const n = try std.posix.read(pipe[0], &out);
1150 const got = out[0..n];
1151 // The tile's own corner: rect.top + label_rows + 1, rect.left + 1.
1152 try std.testing.expect(std.mem.indexOf(u8, got, "\x1b[4;6H") != null);
1153 const from = std.mem.indexOf(u8, got, "\x1b[7m").? + 4;
1154 const to = std.mem.indexOf(u8, got, "\x1b[0m").?;
1155 try std.testing.expectEqualStrings(text[text.len - 10 ..], got[from..to]);
1156 }
src/tui/wallview.zig
Old New
@@ -40,15 +40,9 @@ const wall_host = @import("wall_host.zig");
40 const wall_layout = @import("wall_layout.zig"); 40 const wall_layout = @import("wall_layout.zig");
41 const wall_picker = @import("wall_picker.zig"); 41 const wall_picker = @import("wall_picker.zig");
42 const wall_pump = @import("wall_pump.zig"); 42 const wall_pump = @import("wall_pump.zig");
43 const AddHost = wall_host.AddHost;
44 const AgentLocal = wall_pump.AgentLocal;
45 const BirthNames = wall_host.BirthNames;
46 const ClaimStep = wall_pump.ClaimStep;
47 const Host = wall_host.Host; 43 const Host = wall_host.Host;
48 const PickerAuto = wall_picker.PickerAuto; 44 const PickerAuto = wall_picker.PickerAuto;
49 const PickerBody = wall_picker.PickerBody;
50 const Resolved = wall_host.Resolved; 45 const Resolved = wall_host.Resolved;
51 const TileIdxs = wall_host.TileIdxs;
52 46
53 // `wallview` is this module's face: mux_main reaches these through 47 // `wallview` is this module's face: mux_main reaches these through
54 // the root, wherever inside the module they now live. 48 // the root, wherever inside the module they now live.
@@ -398,7 +392,7 @@ pub const Tile = struct {
398 /// the column is what separates them; a click on a rail column hits no 392 /// the column is what separates them; a click on a rail column hits no
399 /// tile and focus stays put. Under `paint_mu` because `Tile.rect` is 393 /// tile and focus stays put. Under `paint_mu` because `Tile.rect` is
400 /// written under it. 394 /// written under it.
401 fn rectHit(tiles: []Tile, present: []const bool, shared: *Shared, row: u16, col: u16) ?usize { 395 pub fn rectHit(tiles: []Tile, present: []const bool, shared: *Shared, row: u16, col: u16) ?usize {
402 shared.paint_mu.lock(); 396 shared.paint_mu.lock();
403 defer shared.paint_mu.unlock(); 397 defer shared.paint_mu.unlock();
404 for (tiles, present) |*t, p| { 398 for (tiles, present) |*t, p| {
@@ -432,10 +426,10 @@ pub fn stepPresent(present: []const bool, sel: usize, forward: bool) ?usize {
432 /// How long a refusal leaves the second `x` armed. Long enough to read the 426 /// How long a refusal leaves the second `x` armed. Long enough to read the
433 /// count and decide, short enough that the decision is about the session on 427 /// count and decide, short enough that the decision is about the session on
434 /// the screen and not one the user has since walked away from. 428 /// the screen and not one the user has since walked away from.
435 const end_arm_ms: i64 = 3000; 429 pub const end_arm_ms: i64 = 3000;
436 430
437 /// What `Ctrl-\ x` has to act on. 431 /// What `Ctrl-\ x` has to act on.
438 const EndKey = union(enum) { 432 pub const EndKey = union(enum) {
439 /// The daemon owns the two-step; the pump carries this. 433 /// The daemon owns the two-step; the pump carries this.
440 ask: client.SwitchIntent, 434 ask: client.SwitchIntent,
441 /// No session yet: the TILE goes, locally. 435 /// No session yet: the TILE goes, locally.
@@ -445,7 +439,7 @@ const EndKey = union(enum) {
445 }; 439 };
446 440
447 /// Whether `Ctrl-\ x` has anything to ask, and what. 441 /// Whether `Ctrl-\ x` has anything to ask, and what.
448 fn endKey(t: *Tile, now: i64) EndKey { 442 pub fn endKey(t: *Tile, now: i64) EndKey {
449 // A pump that has ended is the only thread that would ever swap `ask` 443 // A pump that has ended is the only thread that would ever swap `ask`
450 // out again, so an ask stored on one goes nowhere for the wall's life. 444 // out again, so an ask stored on one goes nowhere for the wall's life.
451 if (!t.alive.load(.acquire)) return .none; 445 if (!t.alive.load(.acquire)) return .none;
@@ -459,7 +453,7 @@ fn endKey(t: *Tile, now: i64) EndKey {
459 } 453 }
460 454
461 /// Which `x` this is. 455 /// Which `x` this is.
462 fn intentForEnd(t: *const Tile, now: i64) client.SwitchIntent { 456 pub fn intentForEnd(t: *const Tile, now: i64) client.SwitchIntent {
463 // The client never DECIDES to force: the daemon is the only side that 457 // The client never DECIDES to force: the daemon is the only side that
464 // knows who else is attached, so this only remembers being told to ask 458 // knows who else is attached, so this only remembers being told to ask
465 // twice. 459 // twice.
@@ -472,7 +466,7 @@ pub fn onEndReply(t: *Tile, r: proto.EndReply, now: i64) void {
472 } 466 }
473 467
474 /// `n`/`p`: the next TILE, or null when there is nowhere to go. 468 /// `n`/`p`: the next TILE, or null when there is nowhere to go.
475 fn walkTiles(present: []const bool, sel: usize, forward: bool) ?usize { 469 pub fn walkTiles(present: []const bool, sel: usize, forward: bool) ?usize {
476 // Over `present`, which is what the eyes are looking at: the wall is 470 // Over `present`, which is what the eyes are looking at: the wall is
477 // every host's sessions, and the daemon's session ring was only ever 471 // every host's sessions, and the daemon's session ring was only ever
478 // the local host's slice of it. 472 // the local host's slice of it.
@@ -533,7 +527,7 @@ pub fn labelText(
533 /// shared state, and this is also what writes the bytes out. Only called 527 /// shared state, and this is also what writes the bytes out. Only called
534 /// when `label_rows` is nonzero: a one-tile wall owns every row and has no 528 /// when `label_rows` is nonzero: a one-tile wall owns every row and has no
535 /// bar to draw. 529 /// bar to draw.
536 fn paintLabelLocked(t: *Tile) void { 530 pub fn paintLabelLocked(t: *Tile) void {
537 if (t.shared.picker_open.load(.acquire)) return; 531 if (t.shared.picker_open.load(.acquire)) return;
538 // ASCII, not an arrow glyph: this bar is byte-truncated, greppable in 532 // ASCII, not an arrow glyph: this bar is byte-truncated, greppable in
539 // a capture, and must not depend on a font. The unfocused marker is 533 // a capture, and must not depend on a font. The unfocused marker is
@@ -682,7 +676,7 @@ pub fn takeNotice(shared: *Shared, out: []u8) []const u8 {
682 /// 676 ///
683 /// A chunk that does not fit is dropped WHOLE: copying what fits splices two 677 /// A chunk that does not fit is dropped WHOLE: copying what fits splices two
684 /// reads into a command nobody typed; blocking wedges the wall. 678 /// reads into a command nobody typed; blocking wedges the wall.
685 fn sendKeys(t: *Tile, keys: []const u8) void { 679 pub fn sendKeys(t: *Tile, keys: []const u8) void {
686 { 680 {
687 t.in_mu.lock(); 681 t.in_mu.lock();
688 defer t.in_mu.unlock(); 682 defer t.in_mu.unlock();
@@ -745,7 +739,7 @@ pub fn setFocus(tiles: []Tile, shared: *Shared, next: usize) void {
745 739
746 /// Re-cut before the move: `setFocus` releases the outgoing session 740 /// Re-cut before the move: `setFocus` releases the outgoing session
747 /// and needs the true previous focus. 741 /// and needs the true previous focus.
748 fn focusAnswer( 742 pub fn focusAnswer(
749 alloc: std.mem.Allocator, 743 alloc: std.mem.Allocator,
750 tiles: []Tile, 744 tiles: []Tile,
751 present: []const bool, 745 present: []const bool,
@@ -761,7 +755,7 @@ fn focusAnswer(
761 755
762 /// TAKEN, not read: an empty wall has no pump to paint a notice as a 756 /// TAKEN, not read: an empty wall has no pump to paint a notice as a
763 /// banner, and every relayout would repaint a sentence left on this line. 757 /// banner, and every relayout would repaint a sentence left on this line.
764 fn emptyWallHint(shared: *Shared) []const u8 { 758 pub fn emptyWallHint(shared: *Shared) []const u8 {
765 if (shared.notice_len == 0) return "Ctrl-\\ s to pick a host - Ctrl-\\ d to leave"; 759 if (shared.notice_len == 0) return "Ctrl-\\ s to pick a host - Ctrl-\\ d to leave";
766 const said = shared.notice[0..shared.notice_len]; 760 const said = shared.notice[0..shared.notice_len];
767 shared.notice_len = 0; 761 shared.notice_len = 0;
@@ -1142,7 +1136,7 @@ fn showRefusal(tiles: []Tile, shared: *Shared, z: usize) void {
1142 /// What the closing picker owes the wall it hands back: the retry for a 1136 /// What the closing picker owes the wall it hands back: the retry for a
1143 /// claim the popup refused, and — when the popup left a sentence behind — 1137 /// claim the popup refused, and — when the popup left a sentence behind —
1144 /// the focus move that is the only thing which makes a pump take it. 1138 /// the focus move that is the only thing which makes a pump take it.
1145 fn closeNotice(tiles: []Tile, present: []const bool, shared: *Shared) void { 1139 pub fn closeNotice(tiles: []Tile, present: []const bool, shared: *Shared) void {
1146 const z = shared.sel; 1140 const z = shared.sel;
1147 if (z >= tiles.len or !present[z]) return; 1141 if (z >= tiles.len or !present[z]) return;
1148 shared.paint_mu.lock(); 1142 shared.paint_mu.lock();
@@ -1191,7 +1185,7 @@ pub fn tileLabel(alloc: std.mem.Allocator, target: client.Target, name: []const
1191 /// What a pump's ending means for the wall. Only the last present tile 1185 /// What a pump's ending means for the wall. Only the last present tile
1192 /// ending can end the program: a tile that goes quiet while others remain 1186 /// ending can end the program: a tile that goes quiet while others remain
1193 /// narrates itself and the wall goes on, which is what the dead-tile paint is for. 1187 /// narrates itself and the wall goes on, which is what the dead-tile paint is for.
1194 const EndAction = union(enum) { 1188 pub const EndAction = union(enum) {
1195 /// The wall has other tiles: the focus moves to the next present one. 1189 /// The wall has other tiles: the focus moves to the next present one.
1196 /// Its label already says what became of the ended tile. 1190 /// Its label already says what became of the ended tile.
1197 refocus: usize, 1191 refocus: usize,
@@ -1205,14 +1199,14 @@ const EndAction = union(enum) {
1205 1199
1206 /// One sentence in both places it is said: on stderr as `mux: ...`, and in 1200 /// One sentence in both places it is said: on stderr as `mux: ...`, and in
1207 /// the brackets a banner wears. 1201 /// the brackets a banner wears.
1208 fn noticeText(buf: *[128]u8, msg: []const u8) []const u8 { 1202 pub fn noticeText(buf: *[128]u8, msg: []const u8) []const u8 {
1209 const prefix = "mux: "; 1203 const prefix = "mux: ";
1210 const bare = if (std.mem.startsWith(u8, msg, prefix)) msg[prefix.len..] else msg; 1204 const bare = if (std.mem.startsWith(u8, msg, prefix)) msg[prefix.len..] else msg;
1211 return std.fmt.bufPrint(buf, "[{s}]", .{bare}) catch bare; 1205 return std.fmt.bufPrint(buf, "[{s}]", .{bare}) catch bare;
1212 } 1206 }
1213 1207
1214 /// Closed `stdin` ends mux: a wall nobody types at is nowhere to leave a user. 1208 /// Closed `stdin` ends mux: a wall nobody types at is nowhere to leave a user.
1215 fn endAction( 1209 pub fn endAction(
1216 tiles: []Tile, 1210 tiles: []Tile,
1217 present: []const bool, 1211 present: []const bool,
1218 live: usize, 1212 live: usize,
@@ -1346,7 +1340,7 @@ fn awaitingSession(t: *Tile) ?State {
1346 /// `mux` typed straight after a `Ctrl-\ d` would otherwise race its own 1340 /// `mux` typed straight after a `Ctrl-\ d` would otherwise race its own
1347 /// corpse for the session. Bounded so a wedged pump cannot hold the terminal 1341 /// corpse for the session. Bounded so a wedged pump cannot hold the terminal
1348 /// hostage; the process exit closes the socket either way. 1342 /// hostage; the process exit closes the socket either way.
1349 fn awaitDetach(t: *Tile, shared: *Shared) void { 1343 pub fn awaitDetach(t: *Tile, shared: *Shared) void {
1350 // The pump is the only thread that sets `detach_ack`, so one that has 1344 // The pump is the only thread that sets `detach_ack`, so one that has
1351 // already returned can only run the wait out: 400ms of a wall that has 1345 // already returned can only run the wait out: 400ms of a wall that has
1352 // said goodbye. 1346 // said goodbye.
@@ -2244,3925 +2238,13 @@ pub fn run(alloc: std.mem.Allocator, host_specs: []const HostSpec, entry: Entry)
2244 std.posix.exit(exit_code); 2238 std.posix.exit(exit_code);
2245 } 2239 }
2246 2240
2247 test "the tree's stacked cut splits rows with the remainder at the top" { 2241 // Reaching a file is what registers its tests; build.zig gates
2248 const alloc = std.testing.allocator; 2242 // the list.
2249 var tree = layout.Tree.init(alloc); 2243 test {
2250 defer tree.deinit(); 2244 _ = @import("wall_test_harness.zig");
2251 try tree.addFirst(0); 2245 _ = @import("wall_test_host.zig");
2252 try tree.insert(0, 1); 2246 _ = @import("wall_test_layout.zig");
2253 try tree.insert(1, 2); 2247 _ = @import("wall_test_picker.zig");
2254 var f = try tree.flatten(alloc, 25, 80, wall_layout.wallFloors(3), null); 2248 _ = @import("wall_test_pump.zig");
2255 defer f.deinit(alloc); 2249 _ = @import("wall_test_wall.zig");
2256 try std.testing.expectEqual(layout.Rect{ .top = 0, .left = 0, .rows = 9, .cols = 80 }, f.rectOf(0).?);
2257 try std.testing.expectEqual(layout.Rect{ .top = 9, .left = 0, .rows = 8, .cols = 80 }, f.rectOf(1).?);
2258 try std.testing.expectEqual(layout.Rect{ .top = 17, .left = 0, .rows = 8, .cols = 80 }, f.rectOf(2).?);
2259 }
2260
2261 test "the tree refuses a wall that cannot show a content row" {
2262 const alloc = std.testing.allocator;
2263 var tree = layout.Tree.init(alloc);
2264 defer tree.deinit();
2265 // 12 tiles over 23 rows: 1 row each — a label with no content.
2266 try tree.addFirst(0);
2267 for (1..12) |i| try tree.insert(@intCast(i - 1), @intCast(i));
2268 try std.testing.expectError(error.TooSmall, tree.flatten(alloc, 23, 80, wall_layout.wallFloors(12), null));
2269 }
2270
2271 test "the tree refuses a multi-tile cut too thin for the daemon's row floor" {
2272 const alloc = std.testing.allocator;
2273 var tree = layout.Tree.init(alloc);
2274 defer tree.deinit();
2275 try tree.addFirst(0);
2276 try tree.insert(0, 1);
2277 // Two tiles on five rows: each rect is two rows, and under a label
2278 // bar that is one content row — below `min_session_rows`, so the
2279 // daemon refuses the resize and the tile freezes on a stale grid.
2280 try std.testing.expectError(error.TooSmall, tree.flatten(alloc, 5, 80, wall_layout.wallFloors(2), null));
2281 try std.testing.expectError(error.TooSmall, tree.flatten(alloc, 4, 80, wall_layout.wallFloors(2), null));
2282 // Six rows is the first cut that fits: three a rect, two of them
2283 // content under the bar — exactly the daemon's row floor.
2284 var f = try tree.flatten(alloc, 6, 80, wall_layout.wallFloors(2), null);
2285 defer f.deinit(alloc);
2286 try std.testing.expectEqual(@as(u16, 3), f.rectOf(0).?.rows);
2287 try std.testing.expectEqual(@as(u16, 3), f.rectOf(1).?.rows);
2288 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 6 }, .is_tty = false };
2289 shared.label_rows = 1; // two tiles draw a bar
2290 var t0 = Tile{
2291 .r = .{ .target = .{ .sock = "/tmp/x" }, .label = "x", .session = "" },
2292 .rect = f.rectOf(0).?,
2293 .shared = &shared,
2294 .idx = 0,
2295 .wake_r = -1,
2296 .wake_w = -1,
2297 };
2298 var t1 = Tile{
2299 .r = .{ .target = .{ .sock = "/tmp/y" }, .label = "y", .session = "" },
2300 .rect = f.rectOf(1).?,
2301 .shared = &shared,
2302 .idx = 1,
2303 .wake_r = -1,
2304 .wake_w = -1,
2305 };
2306 try std.testing.expectEqual(proto.min_session_rows, t0.viewRows());
2307 try std.testing.expectEqual(proto.min_session_rows, t1.viewRows());
2308 // A one-tile wall draws no bar, so its floor is the daemon's alone.
2309 var one = layout.Tree.init(alloc);
2310 defer one.deinit();
2311 try one.addFirst(0);
2312 var of = try one.flatten(alloc, 2, 80, wall_layout.wallFloors(1), null);
2313 defer of.deinit(alloc);
2314 try std.testing.expectEqual(@as(u16, 2), of.rectOf(0).?.rows);
2315 }
2316
2317 test "birthTile: a chord-born tile creates and offers no agent" {
2318 const alloc = std.testing.allocator;
2319 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
2320 defer shared.tree.deinit();
2321 try shared.tree.addFirst(0);
2322 var tiles: [2]Tile = undefined;
2323 tiles[0] = .{
2324 .r = .{ .target = .{ .sock = "/tmp/a" }, .label = "--sock /tmp/a", .session = "" },
2325 .rect = .{ .top = 0, .left = 0, .rows = 24, .cols = 80 },
2326 .shared = &shared,
2327 .idx = 0,
2328 .wake_r = -1,
2329 .wake_w = -1,
2330 };
2331 var present = [_]bool{ true, false };
2332 var live: usize = 1;
2333 const r: Resolved = .{ .target = .{ .sock = "/tmp/b" }, .label = "--sock /tmp/b#b", .session = "b" };
2334 const at = birthTile(alloc, &tiles, &present, &live, &shared, .{
2335 .r = r,
2336 .from = 0,
2337 .place = .beside_focus,
2338 .creates = true,
2339 .born_from = 0,
2340 }) orelse return error.TestUnexpectedResult;
2341 defer std.posix.close(tiles[at].wake_r);
2342 defer std.posix.close(tiles[at].wake_w);
2343 try std.testing.expectEqual(@as(usize, 1), at);
2344 try std.testing.expect(tiles[at].creates);
2345 try std.testing.expect(!tiles[at].r.agent);
2346 try std.testing.expectEqual(@as(?usize, 0), tiles[at].born_from);
2347 try std.testing.expect(present[1]);
2348 try std.testing.expectEqual(@as(usize, 2), live);
2349 // Two tiles draw a bar, and the bar is set at birth so viewRows is
2350 // right from the first attach.
2351 try std.testing.expectEqual(@as(u16, 1), shared.label_rows);
2352 }
2353
2354 /// Three tiles: two sessions on host 0 and a same-named one on host 1, so a
2355 /// diff that aliased by NAME alone would be caught.
2356 fn diffFixture(shared: *Shared) [3]Tile {
2357 const t0: client.Target = .{ .sock = "/tmp/h0.sock" };
2358 const t1: client.Target = .{ .sock = "/tmp/h1.sock" };
2359 const rect: layout.Rect = .{ .top = 0, .left = 0, .rows = 24, .cols = 80 };
2360 return .{
2361 .{
2362 .r = .{ .target = t0, .label = "--sock /tmp/h0.sock#a", .session = "a" },
2363 .rect = rect,
2364 .shared = shared,
2365 .idx = 0,
2366 .wake_r = -1,
2367 .wake_w = -1,
2368 .host = 0,
2369 },
2370 .{
2371 .r = .{ .target = t0, .label = "--sock /tmp/h0.sock#b", .session = "b" },
2372 .rect = rect,
2373 .shared = shared,
2374 .idx = 1,
2375 .wake_r = -1,
2376 .wake_w = -1,
2377 .host = 0,
2378 },
2379 .{
2380 .r = .{ .target = t1, .label = "--sock /tmp/h1.sock#a", .session = "a" },
2381 .rect = rect,
2382 .shared = shared,
2383 .idx = 2,
2384 .wake_r = -1,
2385 .wake_w = -1,
2386 .host = 1,
2387 },
2388 };
2389 }
2390
2391 test "planHostDiff: names the daemon has and the wall does not are births, tiles it dropped vanish, and another host's same name is untouched" {
2392 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
2393 var tiles = diffFixture(&shared);
2394 var present = [_]bool{ true, true, true };
2395
2396 var births = BirthNames{};
2397 var vanish = TileIdxs{};
2398 // The birth is immediate; the vanish waits for a second list to agree
2399 // (see the grace test below), so this list is asked twice.
2400 wall_host.planHostDiff(&tiles, &present, 3, 0, "a\nc\n", null, &births, &vanish);
2401 try std.testing.expectEqual(@as(usize, 1), births.len);
2402 try std.testing.expectEqualStrings("c", births.get(0));
2403 births.len = 0;
2404 wall_host.planHostDiff(&tiles, &present, 3, 0, "a\nc\n", null, &births, &vanish);
2405 try std.testing.expectEqual(@as(usize, 1), vanish.len);
2406 try std.testing.expectEqual(@as(usize, 1), vanish.get(0));
2407
2408 // Host 1's "a" survives host 0's list saying nothing about it.
2409 births.len = 0;
2410 vanish.len = 0;
2411 wall_host.planHostDiff(&tiles, &present, 3, 1, "a\n", null, &births, &vanish);
2412 try std.testing.expectEqual(@as(usize, 0), births.len + vanish.len);
2413
2414 // An empty list vanishes everything the host had, once every tile has
2415 // spent the grace.
2416 wall_host.planHostDiff(&tiles, &present, 3, 0, "", null, &births, &vanish);
2417 vanish.len = 0;
2418 wall_host.planHostDiff(&tiles, &present, 3, 0, "", null, &births, &vanish);
2419 try std.testing.expectEqual(@as(usize, 2), vanish.len);
2420 try std.testing.expectEqual(@as(usize, 0), births.len);
2421 }
2422
2423 test "planHostDiff: a live pump's tile is not vanished by ONE list that lacks it" {
2424 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
2425 var tiles = diffFixture(&shared);
2426 var present = [_]bool{ true, true, true };
2427
2428 var births = BirthNames{};
2429 var vanish = TileIdxs{};
2430 // A poll can overtake a session's exit: the daemon has already dropped
2431 // the name while the pump has not yet read its `exit_status`. Vanishing
2432 // on that list loses the exit code — on a wall of one, `endedTile` skips
2433 // a tile that is no longer present and mux never leaves.
2434 wall_host.planHostDiff(&tiles, &present, 3, 0, "a\n", null, &births, &vanish);
2435 try std.testing.expectEqual(@as(usize, 0), vanish.len);
2436 try std.testing.expect(tiles[1].missed_once);
2437
2438 // A list that names it again forgives it: a tile does not accumulate
2439 // misses across the seconds it is legitimately live.
2440 wall_host.planHostDiff(&tiles, &present, 3, 0, "a\nb\n", null, &births, &vanish);
2441 try std.testing.expectEqual(@as(usize, 0), vanish.len);
2442 try std.testing.expect(!tiles[1].missed_once);
2443
2444 // Two consecutive lists without it, and it goes.
2445 wall_host.planHostDiff(&tiles, &present, 3, 0, "a\n", null, &births, &vanish);
2446 wall_host.planHostDiff(&tiles, &present, 3, 0, "a\n", null, &births, &vanish);
2447 try std.testing.expectEqual(@as(usize, 1), vanish.len);
2448 try std.testing.expectEqual(@as(usize, 1), vanish.get(0));
2449
2450 // A pump that has ENDED has no exit code left to lose, so its tile
2451 // needs no grace at all.
2452 tiles[0].alive.store(false, .release);
2453 vanish.len = 0;
2454 const only0 = [_]bool{ true, false, false };
2455 wall_host.planHostDiff(&tiles, &only0, 3, 0, "", null, &births, &vanish);
2456 try std.testing.expectEqual(@as(usize, 1), vanish.len);
2457 try std.testing.expectEqual(@as(usize, 0), vanish.get(0));
2458 }
2459
2460 test "planHostDiff: a vanished tile is not present, so the next list does not vanish it twice" {
2461 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
2462 var tiles = diffFixture(&shared);
2463 var present = [_]bool{ true, false, true };
2464
2465 var births = BirthNames{};
2466 var vanish = TileIdxs{};
2467 wall_host.planHostDiff(&tiles, &present, 3, 0, "a\n", null, &births, &vanish);
2468 try std.testing.expectEqual(@as(usize, 0), vanish.len);
2469 // ...and the name is not reborn either: the tile is gone, but the
2470 // daemon no longer lists it, so there is nothing to bring back.
2471 try std.testing.expectEqual(@as(usize, 0), births.len);
2472 }
2473
2474 test "planHostDiff: the session this shell is inside is never born as a tile" {
2475 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
2476 var tiles = diffFixture(&shared);
2477 var present = [_]bool{ true, true, true };
2478
2479 var births = BirthNames{};
2480 var vanish = TileIdxs{};
2481 // The daemon has "a", "b" and "self"; "self" is the shell mux runs in,
2482 // so a tile of it would paint into the grid it is reading.
2483 wall_host.planHostDiff(&tiles, &present, 3, 0, "a\nb\nself\n", "self", &births, &vanish);
2484 try std.testing.expectEqual(@as(usize, 0), births.len);
2485 try std.testing.expectEqual(@as(usize, 0), vanish.len);
2486 }
2487
2488 test "planHostDiff: a name the wire grammar refuses never becomes a tile, however the peer spells it" {
2489 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
2490 var tiles = diffFixture(&shared);
2491 var present = [_]bool{ true, true, true };
2492
2493 var births = BirthNames{};
2494 var vanish = TileIdxs{};
2495 // A whole reply may be `sessions_text_max`, so ONE "name" in it can be
2496 // 1056 bytes; `encodeAttachNamed` memcpys the birth's name into a
2497 // 32-byte tail behind nothing but an assert. Peer bytes are filtered
2498 // where they become a tile, not asserted about at the wire.
2499 const over_long = "x" ** (proto.session_name_max + 1);
2500 const list = "a\n" ++ over_long ++ "\nhas space\n\x1b[2J\nc\n";
2501 wall_host.planHostDiff(&tiles, &present, 3, 0, list, null, &births, &vanish);
2502 try std.testing.expectEqual(@as(usize, 1), births.len);
2503 try std.testing.expectEqualStrings("c", births.get(0));
2504 }
2505
2506 /// The keyboard-thread fixture: `running` stopped, so a pump a diff spawns
2507 /// returns at its first check rather than dialling a socket no test is
2508 /// listening on.
2509 fn stoppedWall(alloc: std.mem.Allocator, shared: *Shared) void {
2510 shared.* = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
2511 shared.tree = layout.Tree.init(alloc);
2512 shared.flat_alloc = alloc;
2513 shared.running.store(false, .release);
2514 }
2515
2516 /// Detached pumps hold `*Tile` into the test's own frame; nothing may return
2517 /// while one is still running. The wake pipes go here too: the WALL never
2518 /// closes one (see `ring`), and a test binary would run out of fds.
2519 fn endPumps(tiles: []Tile) void {
2520 for (tiles) |*t| {
2521 while (t.alive.load(.acquire)) std.Thread.sleep(std.time.ns_per_ms);
2522 }
2523 for (tiles) |*t| {
2524 if (t.wake_r >= 0) std.posix.close(t.wake_r);
2525 if (t.wake_w >= 0) std.posix.close(t.wake_w);
2526 t.wake_r = -1;
2527 t.wake_w = -1;
2528 }
2529 }
2530
2531 fn testHost(shared: *Shared, spelling: []const u8, sock: []const u8) Host {
2532 return .{
2533 .spec = .{ .spelling = spelling, .target = .{ .sock = sock }, .poll_target = .{ .sock = sock } },
2534 .shared = shared,
2535 };
2536 }
2537
2538 fn setList(h: *Host, list: []const u8) void {
2539 @memcpy(h.list[0..list.len], list);
2540 h.list_len = list.len;
2541 h.reachable.store(true, .release);
2542 }
2543
2544 test "applyReadyLists: a host added after the wall opened gets its sessions, and reaches past nothing" {
2545 var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
2546 defer arena.deinit();
2547 const alloc = arena.allocator();
2548 var shared: Shared = undefined;
2549 stoppedWall(alloc, &shared);
2550 var tiles: [8]Tile = undefined;
2551 var present = [_]bool{false} ** 8;
2552 var live: usize = 0;
2553 defer endPumps(tiles[0..live]);
2554 // TWO hosts open the wall and a THIRD arrives from the picker. A fixture
2555 // whose table never grows cannot see a per-host flag sized at the count
2556 // the wall opened with — which is the whole of this.
2557 var table = [_]Host{
2558 testHost(&shared, "box", "/tmp/box.sock"),
2559 testHost(&shared, "vm", "/tmp/vm.sock"),
2560 testHost(&shared, "--sock /tmp/added.sock", "/tmp/added.sock"),
2561 };
2562 setList(&table[2], "late\n");
2563 table[2].list_ready.store(true, .release);
2564
2565 _ = wall_host.applyReadyLists(alloc, &tiles, &present, &live, &shared, table[0..3]);
2566
2567 // The added host's session is a tile, and only the host that reported is
2568 // marked: the two that opened the wall are still owed a first list.
2569 try std.testing.expect(table[2].applied);
2570 try std.testing.expect(!table[0].applied);
2571 try std.testing.expect(!table[1].applied);
2572 try std.testing.expectEqual(@as(usize, 1), live);
2573 try std.testing.expectEqualStrings("late", tiles[0].r.session);
2574 }
2575
2576 // A picker painted into a pipe, drained. Non-blocking on both ends so a
2577 // frame that outgrew the pipe FAILS here rather than parking the suite.
2578 fn pickerFrame(shared: *Shared, r: std.posix.fd_t, host_table: []Host, sel: usize, out: []u8) []const u8 {
2579 wall_picker.paintPicker(shared, host_table, sel, null);
2580 const n = std.posix.read(r, out) catch 0;
2581 return out[0..n];
2582 }
2583
2584 test "paintPicker: the box is centred on the terminal, not pinned to the origin" {
2585 const pipe = try std.posix.pipe2(.{ .NONBLOCK = true });
2586 defer std.posix.close(pipe[0]);
2587 defer std.posix.close(pipe[1]);
2588 // Off-origin is the BASELINE here: a fixture on a terminal the box
2589 // happens to fill is blind to every centring arithmetic mistake.
2590 var shared = Shared{ .out_fd = pipe[1], .size = .{ .cols = 100, .rows = 40 }, .is_tty = true };
2591 var table = [_]Host{
2592 testHost(&shared, "a", "/tmp/a.sock"),
2593 testHost(&shared, "b", "/tmp/b.sock"),
2594 testHost(&shared, "c", "/tmp/c.sock"),
2595 };
2596 for (&table) |*h| {
2597 setList(h, "");
2598 h.applied = true;
2599 }
2600 var buf: [8192]u8 = undefined;
2601 const frame = pickerFrame(&shared, pipe[0], &table, 0, &buf);
2602 // Five rows (header + three hosts + footer) on forty: top = (40-5)/2.
2603 // The box is the terminal's width here, so left is 0 and the columns
2604 // are 1-based CUP.
2605 try std.testing.expect(std.mem.indexOf(u8, frame, "\x1b[18;1H") != null);
2606 try std.testing.expect(std.mem.indexOf(u8, frame, "\x1b[19;1H") != null);
2607 try std.testing.expect(std.mem.indexOf(u8, frame, "\x1b[22;1H") != null);
2608 // ...and nothing above or below the box.
2609 try std.testing.expect(std.mem.indexOf(u8, frame, "\x1b[17;1H") == null);
2610 try std.testing.expect(std.mem.indexOf(u8, frame, "\x1b[23;1H") == null);
2611 // The cursor goes with the popup: a caret blinking in a tile says the
2612 // keys are going there.
2613 try std.testing.expect(std.mem.startsWith(u8, frame, "\x1b[?25l"));
2614 }
2615
2616 test "paintPicker: a box wider than its cap is centred in the columns" {
2617 const pipe = try std.posix.pipe2(.{ .NONBLOCK = true });
2618 defer std.posix.close(pipe[0]);
2619 defer std.posix.close(pipe[1]);
2620 // Wider than `picker_row_max`, so the box is capped and the leftover
2621 // columns are split: (200 - 128) / 2.
2622 var shared = Shared{ .out_fd = pipe[1], .size = .{ .cols = 200, .rows = 10 }, .is_tty = true };
2623 var table = [_]Host{testHost(&shared, "a", "/tmp/a.sock")};
2624 setList(&table[0], "");
2625 table[0].applied = true;
2626 var buf: [8192]u8 = undefined;
2627 const frame = pickerFrame(&shared, pipe[0], &table, 0, &buf);
2628 try std.testing.expect(std.mem.indexOf(u8, frame, "\x1b[4;37H") != null);
2629 }
2630
2631 test "paintPicker: a terminal too small for a box still says which popup has the keys" {
2632 const pipe = try std.posix.pipe2(.{ .NONBLOCK = true });
2633 defer std.posix.close(pipe[0]);
2634 defer std.posix.close(pipe[1]);
2635 var shared = Shared{ .out_fd = pipe[1], .size = .{ .cols = 20, .rows = 3 }, .is_tty = true };
2636 var table = [_]Host{
2637 testHost(&shared, "a", "/tmp/a.sock"),
2638 testHost(&shared, "b", "/tmp/b.sock"),
2639 };
2640 for (&table) |*h| {
2641 setList(h, "");
2642 h.applied = true;
2643 }
2644 var buf: [8192]u8 = undefined;
2645 const frame = pickerFrame(&shared, pipe[0], &table, 0, &buf);
2646 try std.testing.expect(std.mem.indexOf(u8, frame, "hosts") != null);
2647 // Under the minimum the header is the WHOLE popup: no rows, no legend.
2648 // A box that does not fit is worse than a line saying which one it is.
2649 try std.testing.expect(std.mem.indexOf(u8, frame, "Enter/c new session") == null);
2650 try std.testing.expect(std.mem.indexOf(u8, frame, " 1 a") == null);
2651 }
2652
2653 test "paintPicker: the window scrolls so the selected row is always on screen" {
2654 const pipe = try std.posix.pipe2(.{ .NONBLOCK = true });
2655 defer std.posix.close(pipe[0]);
2656 defer std.posix.close(pipe[1]);
2657 // Six rows for the box: header, footer, and four hosts of the eight.
2658 var shared = Shared{ .out_fd = pipe[1], .size = .{ .cols = 60, .rows = 6 }, .is_tty = true };
2659 const names = [_][]const u8{ "h0", "h1", "h2", "h3", "h4", "h5", "h6", "h7" };
2660 var table: [8]Host = undefined;
2661 for (&table, names) |*h, name| {
2662 h.* = testHost(&shared, name, "/tmp/x.sock");
2663 setList(h, "");
2664 h.applied = true;
2665 }
2666 var buf: [8192]u8 = undefined;
2667 const top = pickerFrame(&shared, pipe[0], &table, 0, &buf);
2668 try std.testing.expect(std.mem.indexOf(u8, top, " 1> h0") != null);
2669 try std.testing.expect(std.mem.indexOf(u8, top, "h7") == null);
2670 // The last row: the window has to follow the selection, or `j` walks
2671 // off the bottom of a box that never moves.
2672 var buf2: [8192]u8 = undefined;
2673 const bottom = pickerFrame(&shared, pipe[0], &table, 7, &buf2);
2674 try std.testing.expect(std.mem.indexOf(u8, bottom, " 8> h7") != null);
2675 try std.testing.expect(std.mem.indexOf(u8, bottom, "h0") == null);
2676 }
2677
2678 test "paintPicker: a frame the stamp refuses to write does not eat the notice with it" {
2679 const pipe = try std.posix.pipe2(.{ .NONBLOCK = true });
2680 defer std.posix.close(pipe[0]);
2681 defer std.posix.close(pipe[1]);
2682 var shared = Shared{ .out_fd = pipe[1], .size = .{ .cols = 100, .rows = 40 }, .is_tty = true };
2683 var table = [_]Host{testHost(&shared, "--sock /tmp/a.sock", "/tmp/a.sock")};
2684 setList(&table[0], "");
2685 table[0].applied = true;
2686 wall_picker.paintPicker(&shared, &table, 0, null);
2687
2688 // A notice whose footer is byte-for-byte the legend already on the
2689 // screen: the stamp refuses the write, and taking the notice ahead of
2690 // that check is a sentence consumed by a frame nobody was sent.
2691 setNotice(&shared, " Enter/c new session x forget a add Esc");
2692 wall_picker.paintPicker(&shared, &table, 0, null);
2693 var buf: [96]u8 = undefined;
2694 try std.testing.expectEqualStrings(
2695 " Enter/c new session x forget a add Esc",
2696 takeNotice(&shared, &buf),
2697 );
2698 }
2699
2700 test "paintPicker: replayed into an engine, the popup covers its box and NOT one cell more" {
2701 const alloc = std.testing.allocator;
2702 const cols: u16 = 160;
2703 const rows: u16 = 20;
2704 var screen = try WallScreen.init(alloc, cols, rows);
2705 defer screen.deinit();
2706 // Off-origin in BOTH axes: `picker_row_max` caps the width at 128, so
2707 // left = (160-128)/2, and three hosts make a five-row box at
2708 // top = (20-5)/2. A grep over the frame's CUP sequences cannot see a
2709 // paint that reaches a cell by another route — a different CUP form, a
2710 // wider pad — so this one judges the GRID.
2711 const w: u16 = @intCast(wall_picker.picker_row_max);
2712 const left: u16 = (cols - w) / 2;
2713 const height: u16 = 5;
2714 const top: u16 = (rows - height) / 2;
2715
2716 // A background every untouched cell can be recognised by.
2717 var bg: [cols]u8 = undefined;
2718 @memset(&bg, '.');
2719 var r: u16 = 0;
2720 while (r < rows) : (r += 1) {
2721 var cup: [16]u8 = undefined;
2722 screen.eng.feed(std.fmt.bufPrint(&cup, "\x1b[{d};1H", .{r + 1}) catch unreachable);
2723 screen.eng.feed(&bg);
2724 }
2725 screen.eng.feed("\x1b[H");
2726
2727 var shared = Shared{ .out_fd = screen.w, .size = .{ .cols = cols, .rows = rows }, .is_tty = true };
2728 var table = [_]Host{
2729 testHost(&shared, "--sock /tmp/a.sock", "/tmp/a.sock"),
2730 testHost(&shared, "--sock /tmp/b.sock", "/tmp/b.sock"),
2731 testHost(&shared, "--sock /tmp/c.sock", "/tmp/c.sock"),
2732 };
2733 for (&table) |*h| {
2734 setList(h, "");
2735 h.applied = true;
2736 }
2737 wall_picker.paintPicker(&shared, &table, 1, null);
2738 screen.drain();
2739 const dump = try screen.eng.dumpPlain(alloc);
2740 defer alloc.free(dump);
2741
2742 var i: u16 = 0;
2743 while (i < rows) : (i += 1) {
2744 const l = WallScreen.line(dump, i) orelse return error.NoSuchRow;
2745 try std.testing.expectEqual(@as(usize, cols), l.len);
2746 if (i >= top and i < top + height) {
2747 // The rails and every tile beside the box are outside it.
2748 try std.testing.expectEqualStrings(bg[0..left], l[0..left]);
2749 try std.testing.expectEqualStrings(bg[0..left], l[left + w ..]);
2750 try std.testing.expect(!std.mem.eql(u8, bg[0..w], l[left..][0..w]));
2751 } else {
2752 try std.testing.expectEqualStrings(&bg, l);
2753 }
2754 }
2755 // The selected row is the one the popup says it is, in the grid.
2756 const sel_row = WallScreen.line(dump, top + 2) orelse return error.NoSuchRow;
2757 try std.testing.expect(std.mem.indexOf(u8, sel_row, " 2> ") != null);
2758 }
2759
2760 test "paintPicker: an unchanged frame is not written again" {
2761 const pipe = try std.posix.pipe2(.{ .NONBLOCK = true });
2762 defer std.posix.close(pipe[0]);
2763 defer std.posix.close(pipe[1]);
2764 var shared = Shared{ .out_fd = pipe[1], .size = .{ .cols = 60, .rows = 20 }, .is_tty = true };
2765 var table = [_]Host{
2766 testHost(&shared, "a", "/tmp/a.sock"),
2767 testHost(&shared, "b", "/tmp/b.sock"),
2768 };
2769 for (&table) |*h| {
2770 setList(h, "");
2771 h.applied = true;
2772 }
2773 var buf: [8192]u8 = undefined;
2774 try std.testing.expect(pickerFrame(&shared, pipe[0], &table, 0, &buf).len > 0);
2775 // The pollers repaint this box once a second PER HOST. Rewriting an
2776 // identical screen at that rate is a terminal that never goes quiet,
2777 // which is also every `settle` in the e2e suite.
2778 try std.testing.expectEqual(@as(usize, 0), pickerFrame(&shared, pipe[0], &table, 0, &buf).len);
2779 // A moved selection is a different frame, and is written.
2780 try std.testing.expect(pickerFrame(&shared, pipe[0], &table, 1, &buf).len > 0);
2781 // ...and a cleared stamp is what a relayout leaves behind, so the box
2782 // goes back onto a screen that was wiped under it.
2783 shared.picker_stamp = 0;
2784 try std.testing.expect(pickerFrame(&shared, pipe[0], &table, 1, &buf).len > 0);
2785 }
2786
2787 test "PickerAuto: an empty wall opens the picker once, and a tile takes it back" {
2788 var a: PickerAuto = .{};
2789 // Empty and nothing open: the wall opens it itself, because a blank
2790 // screen is no place to act from.
2791 try std.testing.expectEqual(PickerAuto.Step.open, a.step(true, true, false, false));
2792 // ...ONCE. The Esc that closed it has to leave the one-line text
2793 // standing rather than being reopened over.
2794 try std.testing.expectEqual(PickerAuto.Step.leave, a.step(true, true, false, false));
2795 // A tile arrived: the wall has something to show and gets the screen.
2796 try std.testing.expectEqual(PickerAuto.Step.close, a.step(true, false, true, false));
2797 // ...and only once; the popup is already gone.
2798 try std.testing.expectEqual(PickerAuto.Step.leave, a.step(true, false, false, false));
2799 }
2800
2801 test "PickerAuto: a spelling in progress keeps the popup a tile would have closed" {
2802 var a: PickerAuto = .{};
2803 try std.testing.expectEqual(PickerAuto.Step.open, a.step(true, true, false, false));
2804 // `prompting` layers OVER `picking`: closing on `picking` alone would
2805 // take the editor off the screen and leave it eating every key, and its
2806 // Enter would reach the main switch's dead `.add_tile` arm — no line,
2807 // no notice, and no host added.
2808 try std.testing.expectEqual(PickerAuto.Step.leave, a.step(true, false, true, true));
2809 // The moment the line is submitted or cancelled, the close is owed again.
2810 try std.testing.expectEqual(PickerAuto.Step.close, a.step(true, false, true, false));
2811 }
2812
2813 test "PickerAuto: a picker the user opened is not closed by an arriving tile" {
2814 var a: PickerAuto = .{};
2815 try std.testing.expectEqual(PickerAuto.Step.open, a.step(true, true, false, false));
2816 // Esc, and the user opens it again by hand. Without `taken` the flag
2817 // outlives the close and the next birth force-closes a popup they are
2818 // choosing from.
2819 a.taken();
2820 try std.testing.expectEqual(PickerAuto.Step.leave, a.step(true, false, true, false));
2821 }
2822
2823 test "PickerAuto: the Esc that closes the wall's own popup is not undone by the wall" {
2824 var a: PickerAuto = .{};
2825 try std.testing.expectEqual(PickerAuto.Step.open, a.step(true, true, false, false));
2826 // The close runs `taken` on EVERY exit, the wall's popup included, and
2827 // the wall is still empty afterwards. One flag for both facts reopened
2828 // the box over the one-line text the Esc had just asked for — and on
2829 // that wall `Ctrl-\ d` never reached the keyboard again.
2830 a.taken();
2831 try std.testing.expectEqual(PickerAuto.Step.leave, a.step(true, true, false, false));
2832 try std.testing.expectEqual(PickerAuto.Step.leave, a.step(true, true, false, false));
2833 // A tile arrives and leaves again: THAT emptiness earns its own open.
2834 try std.testing.expectEqual(PickerAuto.Step.leave, a.step(true, false, false, false));
2835 try std.testing.expectEqual(PickerAuto.Step.open, a.step(true, true, false, false));
2836 }
2837
2838 test "PickerAuto: one Esc leaves a user's picker that outlived the wall's last tile" {
2839 var a: PickerAuto = .{};
2840 // The user opens the popup on a wall that still has tiles, and the last
2841 // of them ends underneath it. The emptiness the wall then sees is one
2842 // whose popup is already up, so it is spent without ever opening.
2843 try std.testing.expectEqual(PickerAuto.Step.leave, a.step(true, false, true, false));
2844 try std.testing.expectEqual(PickerAuto.Step.leave, a.step(true, true, true, false));
2845 a.taken();
2846 // The one Esc that closed it is the whole of this: unspent, the wall
2847 // answers it by opening the box straight back over the empty line.
2848 try std.testing.expectEqual(PickerAuto.Step.leave, a.step(true, true, false, false));
2849 }
2850
2851 test "PickerAuto: a wall still dialling spends nothing, so its first known emptiness still opens" {
2852 var a: PickerAuto = .{};
2853 // `is_tty` carries `restore_tried`: the user can open the picker by hand
2854 // while the hosts are still answering, and a wall not yet KNOWN to be
2855 // empty must not spend the open it has not made.
2856 try std.testing.expectEqual(PickerAuto.Step.leave, a.step(false, true, true, false));
2857 a.taken();
2858 try std.testing.expectEqual(PickerAuto.Step.open, a.step(true, true, false, false));
2859 }
2860
2861 test "PickerAuto: a wall still dialling, and a wall with no terminal, open nothing" {
2862 var a: PickerAuto = .{};
2863 // The caller folds `restore_tried` into this argument: a wall that has
2864 // not heard from its hosts yet is not KNOWN to be empty, and a popup
2865 // flashed over tiles that are about to arrive is one nobody asked for.
2866 try std.testing.expectEqual(PickerAuto.Step.leave, a.step(false, true, false, false));
2867 // A piped mux has no screen to put a popup on.
2868 var b: PickerAuto = .{};
2869 try std.testing.expectEqual(PickerAuto.Step.leave, b.step(false, true, false, false));
2870 }
2871
2872 fn claimBench(shared: *Shared, idx: usize) Tile {
2873 return .{
2874 .r = .{ .target = .{ .sock = "/tmp/x" }, .label = "x", .session = "0" },
2875 .rect = .{ .top = 0, .left = 0, .rows = 24, .cols = 80 },
2876 .shared = shared,
2877 .idx = idx,
2878 .wake_r = -1,
2879 .wake_w = -1,
2880 };
2881 }
2882
2883 test "claimAllowed: a stale arm does not claim even when the sink would now admit it" {
2884 const alloc = std.testing.allocator;
2885 const pipe = try std.posix.pipe2(.{ .NONBLOCK = true });
2886 defer std.posix.close(pipe[0]);
2887 defer std.posix.close(pipe[1]);
2888 var shared = Shared{ .out_fd = pipe[1], .size = .{ .cols = 80, .rows = 24 }, .is_tty = true };
2889 // The focus has moved to tile 1; tile 2 is still holding the arm it was
2890 // given before the popup went up.
2891 shared.sel = 1;
2892 var t = claimBench(&shared, 2);
2893 t.claim_pending.store(true, .release);
2894 var core = try interact.Core.initSized(alloc, -1, pipe[1], .{ .cols = 80, .rows = 24 });
2895 defer core.deinit();
2896 core.is_tty = true;
2897
2898 // The whole of this test: the picker has CLOSED, so the default sink
2899 // admits every paint and `claimTerminal` would SUCCEED. Judging the
2900 // claim's refusal cannot see that — there is no refusal left to judge —
2901 // so the focus test has to run first, and `claimFocus` is the one door
2902 // in this file that puts the two in that order.
2903 const rect: proto.Size = .{ .cols = 80, .rows = 24 };
2904 _ = t.claim_pending.swap(false, .acq_rel);
2905 try std.testing.expectEqual(ClaimStep.dropped, wall_pump.claimFocus(&t, &core, rect));
2906
2907 // Nothing claimed, and nothing on the terminal: `session_claim` is the
2908 // mouse modes, and a second tile setting them is what leaves one
2909 // session's modes on the screen with nothing left to take them off.
2910 try std.testing.expectEqual(interact.Claim.none, core.claim);
2911 var buf: [256]u8 = undefined;
2912 try std.testing.expectEqual(@as(usize, 0), std.posix.read(pipe[0], &buf) catch 0);
2913
2914 // The control: the same call on the tile that DOES hold the focus takes
2915 // it, so the guard is a focus test and not a blanket refusal.
2916 shared.sel = 2;
2917 try std.testing.expectEqual(ClaimStep.done, wall_pump.claimFocus(&t, &core, rect));
2918 try std.testing.expectEqual(interact.Claim.session, core.claim);
2919 try std.testing.expect((std.posix.read(pipe[0], &buf) catch 0) > 0);
2920 }
2921
2922 test "afterClaim: a claim the picker refused is armed again for this tile" {
2923 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = true };
2924 // Off-origin: tile 2 of a wall, not tile 0, so an index compared
2925 // against a hard-coded 0 would pass here and nowhere else.
2926 shared.sel = 2;
2927 var t = claimBench(&shared, 2);
2928 try std.testing.expectEqual(ClaimStep.rearmed, wall_pump.afterClaim(&t, false, false, true));
2929 try std.testing.expect(t.claim_pending.load(.acquire));
2930 }
2931
2932 test "afterClaim: an arm does not outlive the focus that earned it" {
2933 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = true };
2934 var t = claimBench(&shared, 2);
2935 // The keyboard moved the focus while the popup was up — a poller's tile
2936 // arriving, or the birth the Enter made. A re-arm that survived it
2937 // fires after the close: two tiles' modes on one terminal, the cursor
2938 // resting on the loser, and the release already consumed so nothing
2939 // takes either off.
2940 shared.sel = 1;
2941 try std.testing.expectEqual(ClaimStep.dropped, wall_pump.afterClaim(&t, false, false, true));
2942 try std.testing.expect(!t.claim_pending.load(.acquire));
2943 }
2944
2945 test "afterClaim: only the SINK's refusal is worth another pass" {
2946 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = true };
2947 shared.sel = 2;
2948 var t = claimBench(&shared, 2);
2949 // A claim that landed owes nothing.
2950 try std.testing.expectEqual(ClaimStep.done, wall_pump.afterClaim(&t, true, true, true));
2951 // A Core that ALREADY holds it answers false too, and re-arming that
2952 // would re-take the focus notice on every pass, forever.
2953 try std.testing.expectEqual(ClaimStep.done, wall_pump.afterClaim(&t, false, true, true));
2954 // No terminal, nothing to hold: a piped `mux` is one tile and no claim.
2955 try std.testing.expectEqual(ClaimStep.done, wall_pump.afterClaim(&t, false, false, false));
2956 // None of the three armed anything.
2957 try std.testing.expect(!t.claim_pending.load(.acquire));
2958 }
2959
2960 test "tilePaintBegin: no tile paints while the picker owns the screen" {
2961 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = true };
2962 var t = Tile{
2963 .r = .{ .target = .{ .sock = "/tmp/x" }, .label = "x", .session = "0" },
2964 .rect = .{ .top = 0, .left = 0, .rows = 24, .cols = 80 },
2965 .shared = &shared,
2966 .idx = 0,
2967 .wake_r = -1,
2968 .wake_w = -1,
2969 };
2970 // The gate the whole popup rests on, and the gate that makes a focus
2971 // CLAIM fail under it (`Core.claimTerminal` writes through the same
2972 // sink) — which is why the pump re-arms `claim_pending` instead of
2973 // treating a false return as "already claimed".
2974 try std.testing.expect(wall_pump.tilePaintBegin(&t));
2975 wall_pump.tilePaintEnd(&t);
2976 shared.picker_open.store(true, .release);
2977 try std.testing.expect(!wall_pump.tilePaintBegin(&t));
2978 // Refused WITHOUT holding the lock: a begin that returned false and
2979 // kept `paint_mu` would wedge the keyboard on its next paint.
2980 try std.testing.expect(shared.paint_mu.tryLock());
2981 shared.paint_mu.unlock();
2982 }
2983
2984 test "pickerRow: a table with a 10 in it pads every number, so no spelling shifts a column" {
2985 var nine_buf: [96]u8 = undefined;
2986 var ten_buf: [96]u8 = undefined;
2987 const nine = wall_picker.pickerRow(&nine_buf, 9, true, "--sock /a", "1 session", false, 40);
2988 const ten = wall_picker.pickerRow(&ten_buf, 10, true, "--sock /a", "1 session", false, 40);
2989 try std.testing.expectEqual(
2990 std.mem.indexOf(u8, nine, "--sock").?,
2991 std.mem.indexOf(u8, ten, "--sock").?,
2992 );
2993
2994 // A table that never reaches 10 spends no column on a digit it has not
2995 // got: the marker is already two wide for the same reason.
2996 var narrow_buf: [96]u8 = undefined;
2997 const narrow = wall_picker.pickerRow(&narrow_buf, 9, false, "--sock /a", "1 session", false, 40);
2998 try std.testing.expectEqualStrings(" 9 --sock", narrow[0..10]);
2999 }
3000
3001 test "pickerRows: every host says what its poller last answered" {
3002 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = true };
3003 // Four hosts, one per state the column can report. A fixture of one
3004 // reachable host is blind to three of them.
3005 var table = [_]Host{
3006 testHost(&shared, "--sock /tmp/a.sock", "/tmp/a.sock"),
3007 testHost(&shared, "box", "/tmp/b.sock"),
3008 testHost(&shared, "quic://gate:4433", "/tmp/c.sock"),
3009 testHost(&shared, "slow", "/tmp/d.sock"),
3010 };
3011 setList(&table[0], "0\nwork\ndev\n");
3012 table[0].applied = true;
3013 setList(&table[1], "");
3014 table[1].reachable.store(false, .release);
3015 table[1].applied = true;
3016 setList(&table[2], "");
3017 table[2].applied = true;
3018 // Never answered: `applied` is what a first list sets, so a host that
3019 // has not reported once is `connecting` and not `no sessions` — the
3020 // two send the user to opposite places.
3021
3022 var body: PickerBody = .{};
3023 wall_picker.pickerRows(&body, &table, 2, 48);
3024
3025 try std.testing.expectEqual(@as(usize, 4), body.n);
3026 try std.testing.expectEqualStrings(" 1 --sock /tmp/a.sock 3 sessions ", body.row(0));
3027 try std.testing.expectEqualStrings(" 2 box unreachable ", body.row(1));
3028 // The selected row wears the same marker a focused tile's bar does.
3029 try std.testing.expectEqualStrings(" 3> quic://gate:4433 no sessions ", body.row(2));
3030 try std.testing.expectEqualStrings(" 4 slow connecting ", body.row(3));
3031 }
3032
3033 test "pickerRows: one session is not 1 sessions" {
3034 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = true };
3035 var table = [_]Host{testHost(&shared, "box", "/tmp/b.sock")};
3036 setList(&table[0], "0\n");
3037 table[0].applied = true;
3038 var body: PickerBody = .{};
3039 wall_picker.pickerRows(&body, &table, 0, 32);
3040 try std.testing.expectEqualStrings(" 1> box 1 session ", body.row(0));
3041 }
3042
3043 test "pickerRows: a spelling wider than the box keeps its tail" {
3044 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = true };
3045 var table = [_]Host{testHost(
3046 &shared,
3047 "--sock /run/user/1000/mux/very/long/path/to/muxd.sock",
3048 "/tmp/b.sock",
3049 )};
3050 setList(&table[0], "0\n");
3051 table[0].applied = true;
3052 var body: PickerBody = .{};
3053 wall_picker.pickerRows(&body, &table, 0, 32);
3054 // Cut from the LEFT: the head of a socket path is what every host on
3055 // one machine has in common, and the tail is what tells them apart.
3056 try std.testing.expectEqualStrings(" 1> path/to/muxd.sock 1 session ", body.row(0));
3057 }
3058
3059 test "pickBirth: Enter on an emptied popup births nothing, not a session on a host just forgotten" {
3060 var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
3061 defer arena.deinit();
3062 const alloc = arena.allocator();
3063 var shared: Shared = undefined;
3064 stoppedWall(alloc, &shared);
3065 var tiles: [4]Tile = undefined;
3066 var present = [_]bool{false} ** 4;
3067 var live: usize = 0;
3068 defer endPumps(tiles[0..live]);
3069 // TWO hosts, both forgotten: the wall the last `x` empties is the wall
3070 // whose popup has no row left to move the selection to, so `sel` still
3071 // indexes a slot — a one-host fixture proves nothing about the index.
3072 var table = [_]Host{
3073 testHost(&shared, "a", "/tmp/a.sock"),
3074 testHost(&shared, "b", "/tmp/b.sock"),
3075 };
3076 for (&table) |*h| {
3077 setList(h, "");
3078 h.applied = true;
3079 h.forgotten.store(true, .release);
3080 }
3081
3082 const at = wall_picker.pickBirth(alloc, &tiles, &present, &live, &shared, &table, 1);
3083
3084 if (at != null) return error.BornOnAForgottenHost;
3085 if (live != 0) return error.AForgottenHostTookASlot;
3086 var buf: [96]u8 = undefined;
3087 try std.testing.expectEqualStrings(
3088 "[no hosts to start a session on - a adds one]",
3089 takeNotice(&shared, &buf),
3090 );
3091 }
3092
3093 test "pickBirth: Enter is an ask — the tile it births may start a daemon, the row it came from may not" {
3094 var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
3095 defer arena.deinit();
3096 const alloc = arena.allocator();
3097 var shared: Shared = undefined;
3098 stoppedWall(alloc, &shared);
3099 var tiles: [4]Tile = undefined;
3100 var present = [_]bool{false} ** 4;
3101 var live: usize = 0;
3102 defer endPumps(tiles[0..live]);
3103 // TWO hosts, and the birth on the second: a one-host fixture cannot
3104 // see a birth that reaches for the wrong row.
3105 var table = [_]Host{
3106 .{
3107 .spec = try resolveHost(alloc, "alpha", null, client.quic_idle_ms_default),
3108 .shared = &shared,
3109 },
3110 .{
3111 .spec = try resolveHost(alloc, "beta", null, client.quic_idle_ms_default),
3112 .shared = &shared,
3113 },
3114 };
3115 for (&table) |*h| {
3116 setList(h, "");
3117 h.applied = true;
3118 }
3119
3120 const at = wall_picker.pickBirth(alloc, &tiles, &present, &live, &shared, &table, 1) orelse
3121 return error.EnterBornNothing;
3122
3123 // The row Enter lands on is often exactly the one the poller calls
3124 // unreachable, and starting that machine's daemon is what choosing it
3125 // means. Nothing else on the wall may: the spec below it is what the
3126 // poller re-dials every second.
3127 try std.testing.expect(tiles[at].r.target.hand.asked);
3128 try std.testing.expect(!table[1].spec.target.hand.asked);
3129 try std.testing.expect(!table[1].spec.poll_target.hand.asked);
3130 }
3131
3132 test "pickerRows: a forgotten host is off the list, and the numbers close up" {
3133 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = true };
3134 var table = [_]Host{
3135 testHost(&shared, "a", "/tmp/a.sock"),
3136 testHost(&shared, "b", "/tmp/b.sock"),
3137 testHost(&shared, "c", "/tmp/c.sock"),
3138 };
3139 for (&table) |*h| {
3140 setList(h, "");
3141 h.applied = true;
3142 }
3143 // The slot STAYS — a poller thread holds the pointer and `Tile.host`
3144 // indexes it — so only the list closes up.
3145 table[1].forgotten.store(true, .release);
3146 var body: PickerBody = .{};
3147 wall_picker.pickerRows(&body, &table, 2, 32);
3148 try std.testing.expectEqual(@as(usize, 2), body.n);
3149 try std.testing.expectEqualStrings(" 1 a no sessions ", body.row(0));
3150 try std.testing.expectEqualStrings(" 2> c no sessions ", body.row(1));
3151 }
3152
3153 test "the empty wall advertises the key the picker really answers to" {
3154 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = true };
3155 const hint = emptyWallHint(&shared);
3156 // The sentence names a chord; this is what stops it outliving the
3157 // binding. `Ctrl-\ s` in the text, `\x1c s` through the filter.
3158 try std.testing.expect(std.mem.indexOf(u8, hint, "Ctrl-\\ s") != null);
3159 var f: interact.PrefixFilter = .{};
3160 var keys = "\x1cs".*;
3161 try std.testing.expectEqual(interact.PrefixFilter.Action.pick_open, f.feed(&keys).action);
3162 }
3163
3164 test "emptyWallHint: a chord an empty wall refuses is said there, and said once" {
3165 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = true };
3166 const way_out = "Ctrl-\\ s to pick a host - Ctrl-\\ d to leave";
3167 try std.testing.expectEqualStrings(way_out, emptyWallHint(&shared));
3168
3169 setNotice(&shared, "[no session to birth beside - Ctrl-\\ s picks a host]");
3170 shared.paint_mu.lock();
3171 defer shared.paint_mu.unlock();
3172 // An empty wall has no pump, and a notice is painted by the pump that
3173 // claims the terminal — so this line is the only place it can be said.
3174 try std.testing.expectEqualStrings(
3175 "[no session to birth beside - Ctrl-\\ s picks a host]",
3176 emptyWallHint(&shared),
3177 );
3178 // Once: every relayout repaints the empty line, and a sentence that
3179 // outlived the keystroke that earned it would never leave the screen.
3180 try std.testing.expectEqualStrings(way_out, emptyWallHint(&shared));
3181 }
3182
3183 test "awaitDetach: a pump that has already returned is not waited on" {
3184 var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
3185 defer arena.deinit();
3186 var shared: Shared = undefined;
3187 stoppedWall(arena.allocator(), &shared);
3188 var t: Tile = .{
3189 .r = .{ .target = .{ .sock = "/tmp/a" }, .label = "--sock /tmp/a", .session = "0" },
3190 .rect = .{ .top = 0, .left = 0, .rows = 24, .cols = 80 },
3191 .shared = &shared,
3192 .idx = 0,
3193 .wake_r = -1,
3194 .wake_w = -1,
3195 .alive = std.atomic.Value(bool).init(false),
3196 };
3197 // The pump is the only thread that sets `detach_ack`, and this one has
3198 // returned: the wait can only ever run out its 400ms bound.
3199 const began = std.time.milliTimestamp();
3200 awaitDetach(&t, &shared);
3201 try std.testing.expect(std.time.milliTimestamp() - began < 100);
3202 }
3203
3204 test "recordHost: a file it cannot write comes BACK — the wall may be on the alternate screen" {
3205 const alloc = std.testing.allocator;
3206 var tmp = try TmpDir.make();
3207 defer tmp.cleanup();
3208 var bad_buf: [256]u8 = undefined;
3209 var ok_buf: [256]u8 = undefined;
3210 var blocker_buf: [256]u8 = undefined;
3211 // A path whose parent is a FILE. `saveBytes` makes missing directories,
3212 // so an absent one is not a failure — this is. The caller has to say so
3213 // out loud, and WHERE it may say it depends on which caller it is:
3214 // stderr before the screen is taken, a notice after.
3215 const blocker = try std.fmt.bufPrint(&blocker_buf, "{s}/notadir", .{tmp.path()});
3216 try wall.saveBytes(blocker, "x");
3217 const bad = try std.fmt.bufPrint(&bad_buf, "{s}/notadir/hosts", .{tmp.path()});
3218 const ok = try std.fmt.bufPrint(&ok_buf, "{s}/hosts", .{tmp.path()});
3219 try std.testing.expect(wall_host.recordHost(alloc, .{ .sock = "/a" }, "--sock /a", bad) != null);
3220 try std.testing.expect(wall_host.recordHost(alloc, .{ .sock = "/a" }, "--sock /a", ok) == null);
3221 // `--via` has no form in the host grammar, and writing nothing down for
3222 // it is not a failure to report.
3223 try std.testing.expect(wall_host.recordHost(alloc, .{ .via = "ssh h muxd proxy" }, "x", bad) == null);
3224 }
3225
3226 test "applyHostList: a host with no live session gets no tile — the wall shows sessions only" {
3227 var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
3228 defer arena.deinit();
3229 const alloc = arena.allocator();
3230 var shared: Shared = undefined;
3231 stoppedWall(alloc, &shared);
3232 var tiles: [4]Tile = undefined;
3233 var present = [_]bool{false} ** 4;
3234 var live: usize = 0;
3235 defer endPumps(tiles[0..live]);
3236 // The two ways a host has nothing to show, side by side: one that never
3237 // answered and one that answered with an empty list.
3238 var table = [_]Host{
3239 testHost(&shared, "down", "/tmp/nobody.sock"),
3240 testHost(&shared, "empty", "/tmp/empty.sock"),
3241 };
3242 table[0].reachable.store(false, .release);
3243 setList(&table[1], "");
3244
3245 // Twice, because a placeholder that is born once is still a placeholder.
3246 for (0..2) |_| {
3247 wall_host.applyHostList(alloc, &tiles, &present, &live, &shared, &table, 0);
3248 wall_host.applyHostList(alloc, &tiles, &present, &live, &shared, &table, 1);
3249 }
3250
3251 try std.testing.expectEqual(@as(usize, 0), live);
3252 try std.testing.expectEqual(@as(usize, 0), presentCount(present[0..]));
3253
3254 // A session on the empty host, and the wall has exactly the one tile.
3255 setList(&table[1], "a\n");
3256 wall_host.applyHostList(alloc, &tiles, &present, &live, &shared, &table, 1);
3257
3258 try std.testing.expectEqual(@as(usize, 1), presentCount(present[0..live]));
3259 try std.testing.expectEqualStrings("a", tiles[0].r.session);
3260 try std.testing.expectEqual(@as(?usize, 1), tiles[0].host);
3261 }
3262
3263 test "applyHostList: a live tile survives one list that lost its session, and goes on the next" {
3264 var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
3265 defer arena.deinit();
3266 const alloc = arena.allocator();
3267 var shared: Shared = undefined;
3268 stoppedWall(alloc, &shared);
3269 var tiles: [4]Tile = undefined;
3270 var present = [_]bool{false} ** 4;
3271 var live: usize = 0;
3272 defer endPumps(tiles[0..live]);
3273 // Two sessions, because the tile that keeps its name is what shows the
3274 // grace is per tile and not a wall-wide pause.
3275 var table = [_]Host{testHost(&shared, "box", "/tmp/box.sock")};
3276 setList(&table[0], "a\nb\n");
3277 wall_host.applyHostList(alloc, &tiles, &present, &live, &shared, &table, 0);
3278 try std.testing.expectEqual(@as(usize, 2), live);
3279
3280 // The wall's pumps are stopped in this fixture, so the liveness the rule
3281 // turns on is stated rather than raced for.
3282 tiles[0].alive.store(true, .release);
3283 tiles[1].alive.store(true, .release);
3284 setList(&table[0], "a\n");
3285 wall_host.applyHostList(alloc, &tiles, &present, &live, &shared, &table, 0);
3286 // Still there: this list may have overtaken an `exit_status` the pump
3287 // has not read yet, and that code is the run's own on a wall of one.
3288 try std.testing.expect(present[1]);
3289 try std.testing.expect(present[0]);
3290
3291 wall_host.applyHostList(alloc, &tiles, &present, &live, &shared, &table, 0);
3292 try std.testing.expect(!present[1]);
3293 try std.testing.expect(present[0]);
3294 }
3295
3296 test "applyHostList: two sessions on an empty wall are two tiles, the first focused" {
3297 var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
3298 defer arena.deinit();
3299 const alloc = arena.allocator();
3300 var shared: Shared = undefined;
3301 stoppedWall(alloc, &shared);
3302 var tiles: [4]Tile = undefined;
3303 var present = [_]bool{false} ** 4;
3304 var live: usize = 0;
3305 defer endPumps(tiles[0..live]);
3306 var table = [_]Host{testHost(&shared, "box", "/tmp/box.sock")};
3307 setList(&table[0], "a\nb\n");
3308
3309 wall_host.applyHostList(alloc, &tiles, &present, &live, &shared, &table, 0);
3310
3311 // The first birth has no leaf to sit beside — an empty tree takes it as
3312 // its root, and the second inserts against it.
3313 try std.testing.expectEqual(@as(usize, 2), live);
3314 try std.testing.expectEqual(@as(usize, 2), shared.tree.count());
3315 try std.testing.expectEqualStrings("a", tiles[0].r.session);
3316 try std.testing.expectEqualStrings("b", tiles[1].r.session);
3317 try std.testing.expectEqual(@as(usize, 0), shared.sel);
3318 try std.testing.expect(tiles[0].claim_pending.load(.acquire));
3319 }
3320
3321 test "applyHostList: one list's tiles are laid out in the order the daemon reported them" {
3322 var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
3323 defer arena.deinit();
3324 const alloc = arena.allocator();
3325 var shared: Shared = undefined;
3326 stoppedWall(alloc, &shared);
3327 defer if (shared.last_flat) |*f| f.deinit(alloc);
3328 var tiles: [4]Tile = undefined;
3329 var present = [_]bool{false} ** 4;
3330 var live: usize = 0;
3331 defer endPumps(tiles[0..live]);
3332 var table = [_]Host{testHost(&shared, "box", "/tmp/box.sock")};
3333 // THREE, because two cannot fail: the first birth roots the tree and the
3334 // second lands after it whatever it anchored on. The third is the one
3335 // that has a choice, and anchoring it back at the focus puts it BETWEEN
3336 // its two siblings.
3337 setList(&table[0], "a\nb\nc\n");
3338
3339 wall_host.applyHostList(alloc, &tiles, &present, &live, &shared, &table, 0);
3340
3341 try std.testing.expectEqual(@as(usize, 3), live);
3342 // Down the screen in the daemon's own order. The tile ARRAY is in that
3343 // order by construction; what this reads is the flatten, which is where
3344 // an anchor that did not move shows up.
3345 const flat = shared.last_flat orelse return error.NoFlat;
3346 const top_a = (flat.rectOf(0) orelse return error.NoRect).top;
3347 const top_b = (flat.rectOf(1) orelse return error.NoRect).top;
3348 const top_c = (flat.rectOf(2) orelse return error.NoRect).top;
3349 try std.testing.expectEqualStrings("a", tiles[0].r.session);
3350 try std.testing.expectEqualStrings("b", tiles[1].r.session);
3351 try std.testing.expectEqualStrings("c", tiles[2].r.session);
3352 try std.testing.expect(top_a < top_b);
3353 try std.testing.expect(top_b < top_c);
3354 }
3355
3356 test "applyHostList: sessions past the wall's capacity are counted in the notice, not dropped in silence" {
3357 var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
3358 defer arena.deinit();
3359 const alloc = arena.allocator();
3360 var shared: Shared = undefined;
3361 stoppedWall(alloc, &shared);
3362 var tiles: [max_tiles]Tile = undefined;
3363 var present = [_]bool{true} ** max_tiles;
3364 var live: usize = max_tiles;
3365 var names: [max_tiles][2]u8 = undefined;
3366 var text: std.ArrayList(u8) = .empty;
3367 for (&tiles, 0..) |*t, i| {
3368 const name = std.fmt.bufPrint(&names[i], "{d}", .{i}) catch unreachable;
3369 t.* = .{
3370 .r = .{ .target = .{ .sock = "/tmp/box.sock" }, .label = "box", .session = name },
3371 .rect = .{ .top = 0, .left = 0, .rows = 24, .cols = 80 },
3372 .shared = &shared,
3373 .idx = i,
3374 .wake_r = -1,
3375 .wake_w = -1,
3376 .host = 0,
3377 .alive = std.atomic.Value(bool).init(false),
3378 };
3379 try text.appendSlice(alloc, name);
3380 try text.append(alloc, '\n');
3381 }
3382 // Two more than the wall can hold.
3383 try text.appendSlice(alloc, "x\ny\n");
3384 var table = [_]Host{testHost(&shared, "box", "/tmp/box.sock")};
3385 setList(&table[0], text.items);
3386
3387 wall_host.applyHostList(alloc, &tiles, &present, &live, &shared, &table, 0);
3388
3389 try std.testing.expectEqual(@as(usize, max_tiles), live);
3390 var buf: [96]u8 = undefined;
3391 try std.testing.expectEqualStrings("[+2 not shown]", takeNotice(&shared, &buf));
3392 }
3393
3394 test "applyHostList: a wall too thin for a second pane takes what fits and retains nothing for the rest" {
3395 // The TESTING allocator, not an arena: what this pins is that a birth
3396 // the wall refuses leaves no allocation behind, and only leak detection
3397 // can say so. The tile that DID fit owns its copies, so the test frees
3398 // exactly those two.
3399 const alloc = std.testing.allocator;
3400 var shared: Shared = undefined;
3401 stoppedWall(alloc, &shared);
3402 // Four rows: one pane fits (`min_session_rows`), two never can — each
3403 // would owe a label bar on top of that floor.
3404 shared.size = .{ .cols = 80, .rows = 4 };
3405 defer shared.tree.deinit();
3406 defer if (shared.last_flat) |*f| f.deinit(alloc);
3407 defer if (shared.base_flat) |*f| f.deinit(alloc);
3408 var tiles: [4]Tile = undefined;
3409 var present = [_]bool{false} ** 4;
3410 var live: usize = 0;
3411 defer endPumps(tiles[0..live]);
3412 defer for (tiles[0..live]) |*t| {
3413 alloc.free(t.r.session);
3414 alloc.free(t.r.label);
3415 };
3416 var table = [_]Host{testHost(&shared, "box", "/tmp/box.sock")};
3417 setList(&table[0], "a\nb\n");
3418
3419 wall_host.applyHostList(alloc, &tiles, &present, &live, &shared, &table, 0);
3420
3421 try std.testing.expectEqual(@as(usize, 1), live);
3422 try std.testing.expectEqualStrings("a", tiles[0].r.session);
3423 var buf: [96]u8 = undefined;
3424 try std.testing.expectEqualStrings("[+1 not shown]", takeNotice(&shared, &buf));
3425 }
3426
3427 test "applyHostList: when a host's last session goes the wall empties, and nothing stands in for the host" {
3428 var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
3429 defer arena.deinit();
3430 const alloc = arena.allocator();
3431 var shared: Shared = undefined;
3432 stoppedWall(alloc, &shared);
3433 var tiles: [4]Tile = undefined;
3434 var present = [_]bool{false} ** 4;
3435 var live: usize = 1;
3436 defer endPumps(tiles[0..live]);
3437 tiles[0] = .{
3438 .r = .{ .target = .{ .sock = "/tmp/box.sock" }, .label = "--sock /tmp/box.sock#a", .session = "a" },
3439 .rect = .{ .top = 0, .left = 0, .rows = 24, .cols = 80 },
3440 .shared = &shared,
3441 .idx = 0,
3442 .wake_r = -1,
3443 .wake_w = -1,
3444 .host = 0,
3445 .alive = std.atomic.Value(bool).init(false),
3446 };
3447 present[0] = true;
3448 try shared.tree.addFirst(0);
3449 var table = [_]Host{testHost(&shared, "box", "/tmp/box.sock")};
3450 // The daemon has nothing left: a restart, or another client ended it.
3451 setList(&table[0], "");
3452
3453 wall_host.applyHostList(alloc, &tiles, &present, &live, &shared, &table, 0);
3454
3455 // The host is up with nothing on it, which the wall says by showing
3456 // nothing: no placeholder tile takes the gone session's place.
3457 try std.testing.expect(!present[0]);
3458 try std.testing.expectEqual(@as(usize, 0), presentCount(present[0..]));
3459 }
3460
3461 test "planHostDiff: a creating tile whose attach has not landed yet is not vanished by the list that raced it" {
3462 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
3463 var tiles = diffFixture(&shared);
3464 // `Ctrl-\ c` picked "b" off the daemon's free list and the tile is
3465 // dialling; `pokeHost` asks for the list at exactly this moment, and
3466 // the daemon answers before the attach that makes "b" lands.
3467 tiles[1].creates = true;
3468 var present = [_]bool{ true, true, true };
3469
3470 var births = BirthNames{};
3471 var vanish = TileIdxs{};
3472 wall_host.planHostDiff(&tiles, &present, 3, 0, "a\n", null, &births, &vanish);
3473 try std.testing.expectEqual(@as(usize, 0), vanish.len);
3474
3475 // ...and once its session exists, the same list does drop it — after
3476 // the one list of grace a live pump gets: a session the daemon HAD and
3477 // no longer lists is gone.
3478 tiles[1].ever_up.store(true, .release);
3479 wall_host.planHostDiff(&tiles, &present, 3, 0, "a\n", null, &births, &vanish);
3480 wall_host.planHostDiff(&tiles, &present, 3, 0, "a\n", null, &births, &vanish);
3481 try std.testing.expectEqual(@as(usize, 1), vanish.len);
3482 try std.testing.expectEqual(@as(usize, 1), vanish.get(0));
3483 }
3484
3485 test "setNoticeIdle: a standing condition waits — a refusal the user just earned is not overwritten" {
3486 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
3487 var buf: [96]u8 = undefined;
3488
3489 // The poll re-derives `[+N not shown]` every second per host, and the
3490 // slot is one: the earned sentence has to survive to a paint.
3491 setNotice(&shared, "[bad host: FlagLikeTarget]");
3492 setNoticeIdle(&shared, "[+2 not shown]");
3493 try std.testing.expectEqualStrings("[bad host: FlagLikeTarget]", takeNotice(&shared, &buf));
3494
3495 // Read, and the standing condition takes the slot it was waiting for.
3496 setNoticeIdle(&shared, "[+2 not shown]");
3497 try std.testing.expectEqualStrings("[+2 not shown]", takeNotice(&shared, &buf));
3498 }
3499
3500 test "hostsOverCapacity: a hosts file past the wall's room is counted out loud, not cut in silence" {
3501 var buf: [64]u8 = undefined;
3502 // Exactly full is not over: the boundary is where a silent cut starts.
3503 try std.testing.expectEqual(@as(?[]const u8, null), wall_host.hostsOverCapacity(&buf, max_tiles));
3504 try std.testing.expectEqual(@as(?[]const u8, null), wall_host.hostsOverCapacity(&buf, 0));
3505 try std.testing.expectEqualStrings(
3506 "[+1 host in the file not shown]",
3507 wall_host.hostsOverCapacity(&buf, max_tiles + 1) orelse "",
3508 );
3509 try std.testing.expectEqualStrings(
3510 "[+9 hosts in the file not shown]",
3511 wall_host.hostsOverCapacity(&buf, max_tiles + 9) orelse "",
3512 );
3513 }
3514
3515 test "endKey: x on a tile that never came up closes the TILE — there is no session to end" {
3516 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
3517 var t = Tile{
3518 .r = .{ .target = .{ .sock = "/tmp/x" }, .label = "x", .session = "0" },
3519 .rect = .{ .top = 0, .left = 0, .rows = 4, .cols = 80 },
3520 .shared = &shared,
3521 .idx = 0,
3522 .wake_r = -1,
3523 .wake_w = -1,
3524 };
3525 t.alive.store(true, .release);
3526
3527 // Parked on its first dial: alive, so the old rule stored an ask that
3528 // `dial` never reads — no effect and no sentence, and the session ended
3529 // under the user if the box ever came back.
3530 t.state = .connecting;
3531 try std.testing.expectEqual(EndKey.drop, endKey(&t, 0));
3532
3533 // Once it has been up, the DAEMON owns the ending, reconnect or not.
3534 t.ever_up.store(true, .release);
3535 t.state = .reconnecting;
3536 try std.testing.expectEqual(EndKey{ .ask = .end }, endKey(&t, 0));
3537 t.state = .up;
3538 try std.testing.expectEqual(EndKey{ .ask = .end }, endKey(&t, 0));
3539
3540 // The armed second press forces; the client never decides that itself.
3541 t.end_armed_until.store(1000, .release);
3542 try std.testing.expectEqual(EndKey{ .ask = .end_force }, endKey(&t, 999));
3543 try std.testing.expectEqual(EndKey{ .ask = .end }, endKey(&t, 1000));
3544
3545 // A pump that has ended has nothing to ask over.
3546 t.alive.store(false, .release);
3547 try std.testing.expectEqual(EndKey.none, endKey(&t, 0));
3548 }
3549
3550 test "endRefusal: a refusal is said in this client's words, never in the peer's bytes" {
3551 try std.testing.expectEqualStrings(
3552 "[no such session on that daemon]",
3553 wall_pump.endRefusal(proto.end_reason.no_session),
3554 );
3555 try std.testing.expectEqualStrings(
3556 "[the daemon could not read the end request]",
3557 wall_pump.endRefusal(proto.end_reason.bad_frame),
3558 );
3559 // Anything the daemon does not say: an OSC title set, a CSI, or 260
3560 // bytes of nothing. All three used to reach the terminal verbatim.
3561 try std.testing.expectEqualStrings(
3562 "[the daemon refused to end this session]",
3563 wall_pump.endRefusal("\x1b]0;pwned\x07"),
3564 );
3565 try std.testing.expectEqualStrings(
3566 "[the daemon refused to end this session]",
3567 wall_pump.endRefusal("\x1b[2J"),
3568 );
3569 try std.testing.expectEqualStrings("[the daemon refused to end this session]", wall_pump.endRefusal(""));
3570 }
3571
3572 test "pickerRepaint: a screen cleared under the spelling editor still owes a paint, carrying the half-typed line" {
3573 var f = interact.PrefixFilter{};
3574 var buf: [interact.PrefixFilter.prompt_max + 4]u8 = undefined;
3575
3576 // Nothing is owed while the popup is not up, whatever else happened.
3577 try std.testing.expect(!wall_picker.pickerRepaint(&buf, &f, true).due);
3578
3579 f.picking = true;
3580 const closed = wall_picker.pickerRepaint(&buf, &f, false);
3581 try std.testing.expect(!closed.due);
3582 try std.testing.expectEqual(@as(?[]const u8, null), closed.line);
3583
3584 // The editor is open and a poll on some OTHER host births or vanishes a
3585 // tile: `relayout` clears the whole screen and zeroes the stamp, which
3586 // is the trigger. Skipping the paint here leaves the user typing into an
3587 // editor that is not on the screen, and every key still goes to it.
3588 f.prompting = true;
3589 var typed = [_]u8{ 'b', 'o', 'x' };
3590 for (&typed) |*c| _ = f.feed(c[0..1]);
3591 const repair = wall_picker.pickerRepaint(&buf, &f, true);
3592 try std.testing.expect(repair.due);
3593 try std.testing.expectEqualStrings(": box_", repair.line orelse "");
3594 }
3595
3596 test "hostState: the row counts the sessions the wall could show, not the runs in the reply" {
3597 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
3598 var h = Host{
3599 .spec = .{ .spelling = "--sock /a", .target = .{ .sock = "/a" }, .poll_target = .{ .sock = "/a" } },
3600 .shared = &shared,
3601 };
3602 var buf: [32]u8 = undefined;
3603
3604 // Never polled, so nothing is known: `no sessions` here would send the
3605 // user to birth on a machine that is not answering.
3606 try std.testing.expectEqualStrings("connecting", wall_picker.hostState(&buf, &h));
3607
3608 h.applied = true;
3609 const listed = "a\nb\nc\n";
3610 @memcpy(h.list[0..listed.len], listed);
3611 h.list_len = listed.len;
3612 try std.testing.expectEqualStrings("3 sessions", wall_picker.hostState(&buf, &h));
3613
3614 // A row saying `4 sessions` beside three tiles is the row lying about
3615 // the wall: `planHostDiff` births through `validSessionName`, so the
3616 // count reads the reply through the same filter.
3617 const with_junk = "a\nb\n" ++ ("x" ** (proto.session_name_max + 1)) ++ "\nc\n";
3618 @memcpy(h.list[0..with_junk.len], with_junk);
3619 h.list_len = with_junk.len;
3620 try std.testing.expectEqualStrings("3 sessions", wall_picker.hostState(&buf, &h));
3621
3622 h.reachable.store(false, .release);
3623 try std.testing.expectEqualStrings("unreachable", wall_picker.hostState(&buf, &h));
3624 }
3625
3626 test "addHost: a forgotten host's slot comes back, so a and x cannot fill the table between them" {
3627 const alloc = std.testing.allocator;
3628 var tmp = try TmpDir.make();
3629 defer tmp.cleanup();
3630 var path_buf: [256]u8 = undefined;
3631 const path = try std.fmt.bufPrint(&path_buf, "{s}/hosts", .{tmp.path()});
3632 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
3633 defer shared.tree.deinit();
3634
3635 // A FULL table is the only one that can see a forgotten slot refused:
3636 // `hosts_live` only ever grew, so 32 adds spent the wall for good even
3637 // with two rows left in the picker.
3638 var table: [2]Host = undefined;
3639 table[0] = .{ .spec = .{ .spelling = "--sock /a", .target = .{ .sock = "/a" }, .poll_target = .{ .sock = "/a" } }, .shared = &shared };
3640 table[1] = .{ .spec = .{ .spelling = "--sock /b", .target = .{ .sock = "/b" }, .poll_target = .{ .sock = "/b" } }, .shared = &shared };
3641 var n: usize = 2;
3642 var buf: [96]u8 = undefined;
3643 try std.testing.expect(wall_host.addHost(alloc, &shared, &table, &n, "--sock /c", null, 30_000, path) == .refused);
3644 try std.testing.expectEqualStrings("[no room on the wall for another host]", takeNotice(&shared, &buf));
3645
3646 // `x` asks the poller to leave; until it has, the slot is still its own.
3647 // A spec overwritten under a live poller is a thread dialling a target
3648 // that has been replaced.
3649 table[0].forgotten.store(true, .release);
3650 try std.testing.expect(wall_host.addHost(alloc, &shared, &table, &n, "--sock /c", null, 30_000, path) == .refused);
3651 try std.testing.expectEqualStrings("[no room on the wall for another host]", takeNotice(&shared, &buf));
3652
3653 // The poller's last act, and the slot is the wall's again.
3654 table[0].poller_done.store(true, .release);
3655 const at = switch (wall_host.addHost(alloc, &shared, &table, &n, "--sock /c", null, 30_000, path)) {
3656 .added => |i| i,
3657 else => return error.HostWasNotAdded,
3658 };
3659 // `run`'s allocator is an ARENA that never returns, so a reused slot's
3660 // old spec is deliberately not freed there; a test outlives its own.
3661 defer alloc.free(table[at].spec.spelling);
3662 try std.testing.expectEqual(@as(usize, 0), at);
3663 try std.testing.expectEqual(@as(usize, 2), n);
3664 try std.testing.expectEqualStrings("/c", table[0].spec.target.sock);
3665 try std.testing.expect(!table[0].forgotten.load(.acquire));
3666 try std.testing.expect(!table[0].poller_done.load(.acquire));
3667 }
3668
3669 test "addHost: the prompt adds a DAEMON — a flag, a session and a host already listed are all refused" {
3670 const alloc = std.testing.allocator;
3671 var tmp = try TmpDir.make();
3672 defer tmp.cleanup();
3673 var path_buf: [256]u8 = undefined;
3674 const path = try std.fmt.bufPrint(&path_buf, "{s}/hosts", .{tmp.path()});
3675 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
3676 defer shared.tree.deinit();
3677 // Two hosts on the wall before the prompt is ever opened: a table that
3678 // only ever held one cannot see an append landing on the wrong row.
3679 var table: [4]Host = undefined;
3680 table[0] = .{ .spec = .{ .spelling = "--sock /a", .target = .{ .sock = "/a" }, .poll_target = .{ .sock = "/a" } }, .shared = &shared };
3681 table[1] = .{ .spec = .{ .spelling = "--sock /b", .target = .{ .sock = "/b" }, .poll_target = .{ .sock = "/b" } }, .shared = &shared };
3682 var n: usize = 2;
3683 var buf: [96]u8 = undefined;
3684
3685 // The typo `mux hosts add -A host` dies at, typed at the prompt
3686 // instead: a host named `-A` is nobody's intent at either mouth.
3687 try std.testing.expect(wall_host.addHost(alloc, &shared, &table, &n, "-A nosuchhost.invalid", null, 30_000, path) == .refused);
3688 try std.testing.expectEqual(@as(usize, 2), n);
3689 try std.testing.expectEqualStrings("[bad host: FlagLikeTarget]", takeNotice(&shared, &buf));
3690
3691 // `#` is the old wall grammar's session split. The prompt names a
3692 // daemon now, and the refusal says which half was one too many.
3693 try std.testing.expect(wall_host.addHost(alloc, &shared, &table, &n, "--sock /c#work", null, 30_000, path) == .refused);
3694 try std.testing.expectEqual(@as(usize, 2), n);
3695 try std.testing.expect(std.mem.indexOf(u8, takeNotice(&shared, &buf), "daemons") != null);
3696
3697 // A real one: it lands in the table AND in the file, because the wall
3698 // the user is looking at and the wall they get back are the same wall.
3699 const at = switch (wall_host.addHost(alloc, &shared, &table, &n, "--sock /c", null, 30_000, path)) {
3700 .added => |i| i,
3701 else => return error.HostWasNotAdded,
3702 };
3703 // `run`'s allocator never returns, so the table's copy is deliberately
3704 // never freed there; a test outlives its allocator's bookkeeping.
3705 defer alloc.free(table[at].spec.spelling);
3706 try std.testing.expectEqual(@as(usize, 2), at);
3707 try std.testing.expectEqual(@as(usize, 3), n);
3708 try std.testing.expectEqualStrings("/c", table[2].spec.target.sock);
3709 var h = try hosts.load(alloc, path);
3710 defer h.deinit(alloc);
3711 try std.testing.expect(h.has("--sock /c"));
3712
3713 // Twice is once: the poller is already asking that daemon for its
3714 // sessions, and a second one would tile every one of them again. It
3715 // says so and names the row, because an unchanged popup and an unmoved
3716 // selection is the prompt answering a real host with nothing at all.
3717 try std.testing.expectEqual(
3718 AddHost{ .listed = 2 },
3719 wall_host.addHost(alloc, &shared, &table, &n, "--sock /c", null, 30_000, path),
3720 );
3721 try std.testing.expectEqual(@as(usize, 3), n);
3722 try std.testing.expectEqualStrings("[that host is already on the wall]", takeNotice(&shared, &buf));
3723
3724 // Full is said out loud, not swallowed.
3725 n = table.len;
3726 try std.testing.expect(wall_host.addHost(alloc, &shared, &table, &n, "--sock /d", null, 30_000, path) == .refused);
3727 try std.testing.expectEqualStrings("[no room on the wall for another host]", takeNotice(&shared, &buf));
3728 }
3729
3730 test "x on a tile whose daemon refused arms a second press, and the window closes after 3 s" {
3731 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
3732 defer shared.tree.deinit();
3733 var t: Tile = .{
3734 .r = .{ .target = .{ .sock = "/tmp/a" }, .label = "--sock /tmp/a", .session = "work" },
3735 .rect = .{ .top = 0, .left = 0, .rows = 24, .cols = 80 },
3736 .shared = &shared,
3737 .idx = 0,
3738 .wake_r = -1,
3739 .wake_w = -1,
3740 };
3741 // A first `x` is never a force: the daemon is the one that knows who
3742 // else is attached, so the client asks before it insists.
3743 try std.testing.expectEqual(client.SwitchIntent.end, intentForEnd(&t, 1000));
3744 onEndReply(&t, .{ .accepted = false, .others = 2, .reason = "others attached" }, 1000);
3745 try std.testing.expectEqual(@as(i64, 1000 + end_arm_ms), t.end_armed_until.load(.acquire));
3746 try std.testing.expectEqual(client.SwitchIntent.end_force, intentForEnd(&t, 2500));
3747
3748 // The window SHUTS. Without this an `x` typed minutes later, on a
3749 // session that has picked up watchers since, kills them without asking.
3750 try std.testing.expectEqual(client.SwitchIntent.end, intentForEnd(&t, 4001));
3751
3752 // An accepted end disarms rather than leaving the window standing: the
3753 // reply for the tile that is going must not force the next one.
3754 t.end_armed_until.store(9000, .release);
3755 onEndReply(&t, .{ .accepted = true, .others = 0, .reason = "" }, 5000);
3756 try std.testing.expectEqual(@as(i64, 0), t.end_armed_until.load(.acquire));
3757 }
3758
3759 test "n/p walk the wall's tiles: a hole is stepped over, and a wall of one has nowhere to go" {
3760 // Three slots with the middle one vanished, because the walk `n` used
3761 // to do was one daemon's session ring and the wall is every host's
3762 // sessions — a fixture of two adjacent tiles cannot see the hole.
3763 const three = [_]bool{ true, false, true };
3764 try std.testing.expectEqual(@as(?usize, 2), walkTiles(&three, 0, true));
3765 try std.testing.expectEqual(@as(?usize, 0), walkTiles(&three, 2, true));
3766 try std.testing.expectEqual(@as(?usize, 2), walkTiles(&three, 0, false));
3767 try std.testing.expectEqual(@as(?usize, 0), walkTiles(&three, 2, false));
3768
3769 // One tile is its own neighbour, which is not a move: a focus that
3770 // "moves" to itself releases the terminal and re-claims it for nothing.
3771 const one = [_]bool{ false, true, false };
3772 try std.testing.expectEqual(@as(?usize, null), walkTiles(&one, 1, true));
3773 try std.testing.expectEqual(@as(?usize, null), walkTiles(&one, 1, false));
3774
3775 const none = [_]bool{ false, false };
3776 try std.testing.expectEqual(@as(?usize, null), walkTiles(&none, 0, true));
3777 }
3778
3779 test "birthTile: no room is null and the tree is left as it was" {
3780 const alloc = std.testing.allocator;
3781 // Four rows cannot hold two stacked tiles under a bar: `wallFloors`
3782 // asks min_session_rows + 1 of each. The leaf count pins the undo —
3783 // a birth that refused and kept its insert would leave the tree one
3784 // leaf wider than the wall.
3785 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 4 }, .is_tty = false };
3786 defer shared.tree.deinit();
3787 try shared.tree.addFirst(0);
3788 var tiles: [2]Tile = undefined;
3789 tiles[0] = .{
3790 .r = .{ .target = .{ .sock = "/tmp/a" }, .label = "--sock /tmp/a", .session = "" },
3791 .rect = .{ .top = 0, .left = 0, .rows = 4, .cols = 80 },
3792 .shared = &shared,
3793 .idx = 0,
3794 .wake_r = -1,
3795 .wake_w = -1,
3796 };
3797 var present = [_]bool{ true, false };
3798 var live: usize = 1;
3799 const r: Resolved = .{ .target = .{ .sock = "/tmp/b" }, .label = "--sock /tmp/b#b", .session = "b" };
3800 try std.testing.expectEqual(@as(?usize, null), birthTile(alloc, &tiles, &present, &live, &shared, .{
3801 .r = r,
3802 .from = 0,
3803 .place = .beside_focus,
3804 .creates = true,
3805 .born_from = 0,
3806 }));
3807 try std.testing.expectEqual(@as(usize, 1), live);
3808 try std.testing.expect(!present[1]);
3809 try std.testing.expectEqual(@as(usize, 1), shared.tree.count());
3810 }
3811
3812 test "birthTile: a beside wall admits more panes than rows/3" {
3813 const alloc = std.testing.allocator;
3814 // Nine panes side by side on 24x200: every one keeps all 24 rows and
3815 // 8 rails plus 9 columns of 21 fit inside 200. A rows/n admission test
3816 // refuses this at the 9th (24/9 = 2), capping ANY terminal at rows/3
3817 // panes however wide it is. The tree owns the answer.
3818 const n = 9;
3819 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 200, .rows = 24 }, .is_tty = false };
3820 defer shared.tree.deinit();
3821 try shared.tree.addFirst(0);
3822 var tiles: [n]Tile = undefined;
3823 var present = [_]bool{false} ** n;
3824 var live: usize = 0;
3825 var i: u8 = 0;
3826 while (i < n - 1) : (i += 1) {
3827 if (i > 0) {
3828 try shared.tree.insert(i - 1, i);
3829 // `insert` wraps a bare root leaf in `.stacked`; a wide
3830 // terminal's wall is cut the other way (`rootOrient`).
3831 if (i == 1) shared.tree.setRootOrient(.beside);
3832 }
3833 tiles[i] = .{
3834 .r = .{ .target = .{ .sock = "/tmp/a" }, .label = "--sock /tmp/a", .session = "" },
3835 .rect = .{ .top = 0, .left = 0, .rows = 24, .cols = 21 },
3836 .shared = &shared,
3837 .idx = i,
3838 .wake_r = -1,
3839 .wake_w = -1,
3840 };
3841 present[i] = true;
3842 live += 1;
3843 }
3844 const r: Resolved = .{ .target = .{ .sock = "/tmp/b" }, .label = "--sock /tmp/b#b", .session = "b" };
3845 const at = birthTile(alloc, &tiles, &present, &live, &shared, .{
3846 .r = r,
3847 .from = n - 2,
3848 .place = .beside_focus,
3849 .creates = true,
3850 .born_from = n - 2,
3851 // Borrowed, so the tile gets copies of its own: these are literals,
3852 // and `Birth.borrowed = false` promises the slot's next reuse two
3853 // slices it may free.
3854 .borrowed = true,
3855 }) orelse return error.TestUnexpectedResult;
3856 defer std.posix.close(tiles[at].wake_r);
3857 defer std.posix.close(tiles[at].wake_w);
3858 defer alloc.free(tiles[at].r.session);
3859 defer alloc.free(tiles[at].r.label);
3860 try std.testing.expectEqual(@as(usize, n), shared.tree.count());
3861 // Admitted AND habitable: every pane keeps the full height and clears
3862 // the column floor, which is what makes the refusal wrong.
3863 const flat = try shared.tree.flatten(alloc, 24, 200, wall_layout.wallFloors(n), null);
3864 defer flat.deinit(alloc);
3865 try std.testing.expectEqual(@as(usize, n), flat.placed.len);
3866 for (flat.placed) |p| {
3867 try std.testing.expectEqual(@as(u16, 24), p.rect.rows);
3868 try std.testing.expect(p.rect.cols >= proto.min_session_cols);
3869 }
3870 }
3871
3872 test "birthTile: a vanished digit is taken back, and not before its pump returned" {
3873 const alloc = std.testing.allocator;
3874 // Five slots on a wall wide enough for all of them: the claim is which
3875 // digit a birth lands on, so no refusal may decide it instead.
3876 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 200, .rows = 24 }, .is_tty = false };
3877 defer shared.tree.deinit();
3878 try shared.tree.addFirst(0);
3879 var tiles: [5]Tile = undefined;
3880 var present = [_]bool{ true, false, false, false, false };
3881 var live: usize = 1;
3882 const target: client.Target = .{ .sock = "/tmp/a" };
3883 tiles[0] = .{
3884 .r = .{ .target = target, .label = "--sock /tmp/a#0", .session = "0" },
3885 .rect = .{ .top = 0, .left = 0, .rows = 24, .cols = 200 },
3886 .shared = &shared,
3887 .idx = 0,
3888 .wake_r = -1,
3889 .wake_w = -1,
3890 };
3891 // Every other tile is born the way a host's poll births one, so the
3892 // copies a reuse has to free are the real owned ones.
3893 defer for (tiles[1..], present[1..]) |*t, p| {
3894 if (!p) continue;
3895 alloc.free(t.r.session);
3896 alloc.free(t.r.label);
3897 std.posix.close(t.wake_r);
3898 std.posix.close(t.wake_w);
3899 };
3900 const born = struct {
3901 fn at(a: std.mem.Allocator, ts: []Tile, ps: []bool, lv: *usize, sh: *Shared, tg: client.Target, name: []const u8, host: ?usize) ?usize {
3902 return birthTile(a, ts, ps, lv, sh, .{
3903 .r = .{ .target = tg, .label = "", .session = name },
3904 .from = 0,
3905 .place = .beside_focus,
3906 .creates = true,
3907 .born_from = 0,
3908 .host = host,
3909 .borrowed = true,
3910 });
3911 }
3912 }.at;
3913
3914 try std.testing.expectEqual(@as(?usize, 1), born(alloc, &tiles, &present, &live, &shared, target, "1", 3));
3915 try std.testing.expectEqual(@as(?usize, 2), born(alloc, &tiles, &present, &live, &shared, target, "2", 3));
3916
3917 // The middle tile leaves, carrying a full set of dirt: every one of
3918 // these is a field whose stale value would be a lie about the NEXT
3919 // tile in the slot — a session that was never up, a list that never
3920 // missed it, an `x` nobody pressed, a host that does not own it.
3921 tiles[1].ever_up.store(true, .release);
3922 tiles[1].missed_once = true;
3923 tiles[1].end_armed_until.store(9000, .release);
3924 const doorbell = tiles[1].wake_r;
3925 vanishTile(&tiles, &present, &shared, 1, null);
3926
3927 // Its pump has not returned, so the digit is not free yet: the birth
3928 // appends rather than hand a live thread's `*Tile` to a new tile.
3929 try std.testing.expectEqual(@as(?usize, 3), born(alloc, &tiles, &present, &live, &shared, target, "3", 3));
3930
3931 tiles[1].pump_done.store(true, .release);
3932 try std.testing.expectEqual(@as(?usize, 1), born(alloc, &tiles, &present, &live, &shared, target, "1", null));
3933 // Reuse, not growth: the wall is four tiles wide, not five.
3934 try std.testing.expectEqual(@as(usize, 4), live);
3935 try std.testing.expectEqual(@as(usize, 1), tiles[1].idx);
3936 try std.testing.expect(!tiles[1].ever_up.load(.acquire));
3937 try std.testing.expect(!tiles[1].missed_once);
3938 try std.testing.expectEqual(@as(i64, 0), tiles[1].end_armed_until.load(.acquire));
3939 try std.testing.expectEqual(@as(?usize, null), tiles[1].host);
3940 try std.testing.expect(!tiles[1].pump_done.load(.acquire));
3941 // The doorbell is the slot's, not the tile's: a fresh pipe per reuse
3942 // would leak the fd pair the old one is never closed on.
3943 try std.testing.expectEqual(doorbell, tiles[1].wake_r);
3944 }
3945
3946 test "birthTile: the digit's next tile frees the copies an unborrowed birth handed over" {
3947 const alloc = std.testing.allocator;
3948 // `Birth.borrowed = false` hands the tile two slices outright, and the
3949 // birth that takes its digit back is what frees them. The testing
3950 // allocator is the whole oracle: a slice it never handed out aborts the
3951 // free, and one nobody frees is reported leaked — which is what a
3952 // caller passing a literal and a reuse that kept the old copies each
3953 // look like.
3954 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 200, .rows = 24 }, .is_tty = false };
3955 defer shared.tree.deinit();
3956 try shared.tree.addFirst(0);
3957 var tiles: [3]Tile = undefined;
3958 var present = [_]bool{ true, false, false };
3959 var live: usize = 1;
3960 const target: client.Target = .{ .sock = "/tmp/a" };
3961 tiles[0] = .{
3962 .r = .{ .target = target, .label = "--sock /tmp/a#0", .session = "0" },
3963 .rect = .{ .top = 0, .left = 0, .rows = 24, .cols = 200 },
3964 .shared = &shared,
3965 .idx = 0,
3966 .wake_r = -1,
3967 .wake_w = -1,
3968 };
3969 // The chord's road, verbatim: the caller allocates, `birthTile` takes
3970 // the slices as they are.
3971 const handed = struct {
3972 fn birth(
3973 a: std.mem.Allocator,
3974 ts: []Tile,
3975 ps: []bool,
3976 lv: *usize,
3977 sh: *Shared,
3978 tg: client.Target,
3979 name: []const u8,
3980 ) ?usize {
3981 const session = a.dupe(u8, name) catch return null;
3982 const label = tileLabel(a, tg, session) catch {
3983 a.free(session);
3984 return null;
3985 };
3986 return birthTile(a, ts, ps, lv, sh, .{
3987 .r = .{ .target = tg, .label = label, .session = session },
3988 .from = 0,
3989 .place = .beside_focus,
3990 .creates = true,
3991 .born_from = 0,
3992 });
3993 }
3994 }.birth;
3995
3996 const first = handed(alloc, &tiles, &present, &live, &shared, target, "1") orelse
3997 return error.TestUnexpectedResult;
3998 try std.testing.expectEqual(@as(usize, 1), first);
3999 defer if (present[1]) {
4000 alloc.free(tiles[1].r.session);
4001 alloc.free(tiles[1].r.label);
4002 std.posix.close(tiles[1].wake_r);
4003 std.posix.close(tiles[1].wake_w);
4004 };
4005
4006 vanishTile(&tiles, &present, &shared, 1, null);
4007 tiles[1].pump_done.store(true, .release);
4008 // The same digit, so the free under test is one this birth really did.
4009 try std.testing.expectEqual(
4010 @as(?usize, 1),
4011 handed(alloc, &tiles, &present, &live, &shared, target, "2"),
4012 );
4013 }
4014
4015 test "labelText: the state word survives truncation at every width" {
4016 var buf: [256]u8 = undefined;
4017 // A label longer than any bar, so truncation is what is under test and
4018 // not the label's own length.
4019 const long = "x" ** 400;
4020
4021 // The markers are the ones a bar actually carries: three bytes with the
4022 // chord digit, four for tiles 10 and up. Every one of them comes out of
4023 // the label's cut, so the marker width is part of what this pins — a
4024 // two-byte marker exercises no bar that ships.
4025
4026 // Narrow: the label is cut, the bar fits the terminal, the state stays.
4027 const narrow = labelText(&buf, 30, "1> ", long, "up");
4028 if (!std.mem.endsWith(u8, narrow, " [up]")) return error.OneDigitNarrowBarLostTheStateWord;
4029 if (narrow.len > 30) return error.OneDigitNarrowBarOverranTheTerminal;
4030
4031 // The same width with the widest marker: the two extra bytes come off
4032 // the label, never off the state.
4033 const narrow2 = labelText(&buf, 30, "12 ", long, "up");
4034 if (!std.mem.endsWith(u8, narrow2, " [up]")) return error.TwoDigitNarrowBarLostTheStateWord;
4035 if (narrow2.len > 30) return error.TwoDigitNarrowBarOverranTheTerminal;
4036
4037 // Wide: `cols` alone would have asked for more than `buf` holds, and the
4038 // failed bufPrint used to hand back the buffer's undefined bytes — with
4039 // the state word among what was lost.
4040 const wide = labelText(&buf, 300, "12> ", long, "reconnecting");
4041 if (!std.mem.endsWith(u8, wide, " [reconnecting]")) return error.TwoDigitWideBarLostTheStateWord;
4042 if (wide.len > buf.len) return error.TwoDigitWideBarOverranItsBuffer;
4043
4044 // Wider than `buf` with the longest status word a bar can carry: the
4045 // bound that matters is `buf`'s, and it is not `cols`'.
4046 const widest = labelText(&buf, 400, "12 ", long, State.reconnecting.word());
4047 if (!std.mem.endsWith(u8, widest, " [reconnecting]")) return error.TwoDigitWidestBarLostTheStateWord;
4048 if (widest.len > buf.len) return error.TwoDigitWidestBarOverranItsBuffer;
4049 }
4050
4051 /// A wall of `n` tiles with none forgotten.
4052 fn allPresent(comptime n: usize) [n]bool {
4053 return [_]bool{true} ** n;
4054 }
4055
4056 test "showsSelf: the wall never tiles the session the walling shell is standing in" {
4057 const sock = "/run/user/1000/muxd.sock";
4058 const target: client.Target = .{ .sock = sock };
4059
4060 // The case: `mux --session 1` typed in session 0's shell. Session 0 is
4061 // on the host's own list, and tiling it would paint that session's
4062 // stripe into the grid it is reading.
4063 try std.testing.expect(showsSelf(target, "0", sock, "0"));
4064 // The empty name is the wire's spelling of the default, and the daemon
4065 // plants the resolved one.
4066 try std.testing.expect(showsSelf(target, "", sock, "0"));
4067 try std.testing.expect(showsSelf(target, "work", sock, "work"));
4068
4069 // Every other session of the same daemon is exactly what the wall is
4070 // for — it must not drop those.
4071 try std.testing.expect(!showsSelf(target, "1", sock, "0"));
4072 try std.testing.expect(!showsSelf(target, "", sock, "1"));
4073 // A different daemon's socket is a different session whatever it is
4074 // called.
4075 try std.testing.expect(!showsSelf(target, "0", "/run/user/1000/other.sock", "0"));
4076 // Not a unix socket: a quic:// or --via target cannot be the daemon
4077 // whose shell planted these.
4078 try std.testing.expect(!showsSelf(.{ .via = "ssh host muxd proxy" }, "0", sock, "0"));
4079
4080 // Unset, and the emptied spelling of unset that the self-attach
4081 // refusal names as the way to override it.
4082 try std.testing.expect(!showsSelf(target, "0", null, "0"));
4083 try std.testing.expect(!showsSelf(target, "0", sock, null));
4084 try std.testing.expect(!showsSelf(target, "0", "", "0"));
4085 try std.testing.expect(!showsSelf(target, "0", sock, ""));
4086 }
4087
4088 /// Only the fields `endAction` reads: a whole Tile describes the pump, not
4089 /// the rule.
4090 fn endBench(
4091 tiles: []Tile,
4092 shared: *Shared,
4093 ended: usize,
4094 reason: EndReason,
4095 code: u8,
4096 born_from: ?usize,
4097 ) void {
4098 for (tiles, 0..) |*t, i| {
4099 t.* = .{
4100 .r = .{ .target = .{ .sock = "/s" }, .label = "t", .session = "" },
4101 .rect = .{ .top = 0, .left = 0, .rows = 2, .cols = 80 },
4102 .shared = shared,
4103 .idx = i,
4104 };
4105 }
4106 tiles[ended].end.store(@intFromEnum(reason), .release);
4107 tiles[ended].code.store(code, .release);
4108 tiles[ended].born_from = born_from;
4109 }
4110
4111 test "sendKeys: a chunk that does not fit is dropped whole, and says so" {
4112 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
4113 var t = Tile{
4114 .r = .{ .target = .{ .sock = "/tmp/x" }, .label = "x", .session = "" },
4115 .rect = .{ .top = 0, .left = 0, .rows = 4, .cols = 80 },
4116 .shared = &shared,
4117 .idx = 0,
4118 // -1 both ends: `ring` writes to the doorbell and ignores the
4119 // failure, which is exactly what it promises to do.
4120 .wake_r = -1,
4121 .wake_w = -1,
4122 };
4123
4124 const head = "abc";
4125 sendKeys(&t, head);
4126 try std.testing.expectEqual(@as(usize, 3), t.in_len);
4127 try std.testing.expect(!t.in_dropped.load(.acquire));
4128
4129 // One byte more than the room left. The old code copied what fit.
4130 const too_big = [_]u8{'z'} ** (mailbox_max - 2);
4131 sendKeys(&t, &too_big);
4132 try std.testing.expectEqual(@as(usize, 3), t.in_len);
4133 try std.testing.expect(t.in_dropped.load(.acquire));
4134
4135 // ...and the mailbox still holds exactly what was typed before it, so
4136 // the next chunk cannot splice onto a half-delivered one.
4137 sendKeys(&t, "def");
4138 var out: [mailbox_max]u8 = undefined;
4139 try std.testing.expectEqualStrings("abcdef", wall_pump.takeKeys(&t, &out));
4140
4141 // Exactly filling it is not overflow — the bound is `>`, not `>=`.
4142 const exact = [_]u8{'q'} ** mailbox_max;
4143 t.in_dropped.store(false, .release);
4144 sendKeys(&t, &exact);
4145 try std.testing.expectEqual(@as(usize, mailbox_max), t.in_len);
4146 try std.testing.expect(!t.in_dropped.load(.acquire));
4147 }
4148
4149 test "labelText: a dropped chunk is narrated beside the state that caused it" {
4150 // The bar's own format, asserted through the same truncation path the
4151 // states go through, because "reconnecting, input dropped" is now the
4152 // longest thing a bar can say and `buf`'s bound is what it tests.
4153 var buf: [256]u8 = undefined;
4154 const long = "x" ** 400;
4155 const said = labelText(&buf, 300, "> ", long, "reconnecting, input dropped");
4156 try std.testing.expect(std.mem.endsWith(u8, said, " [reconnecting, input dropped]"));
4157 try std.testing.expect(said.len <= buf.len);
4158 }
4159
4160 test "noticeText: the banner says what the exit line says, whichever sentence it is" {
4161 var buf: [128]u8 = undefined;
4162 // Both sentences a vanish can carry. A notice that names a cause the
4163 // stderr line contradicts is worse than either alone.
4164 try std.testing.expectEqualStrings(
4165 "[cannot create a new session (daemon full?)]",
4166 noticeText(&buf, "mux: cannot create a new session (daemon full?)"),
4167 );
4168 try std.testing.expectEqualStrings(
4169 "[cannot create a new session (daemon unreachable)]",
4170 noticeText(&buf, "mux: cannot create a new session (daemon unreachable)"),
4171 );
4172 // A sentence that never wore the prefix keeps all of its words.
4173 try std.testing.expectEqualStrings("[detached]", noticeText(&buf, "detached"));
4174 }
4175
4176 test "resolveHost refuses a sun_path-overflowing sock path" {
4177 const alloc = std.testing.allocator;
4178 // Refused at usage altitude, not at a connect that fails with a
4179 // truncated sun_path nobody typed.
4180 const long = "--sock /" ++ "x" ** 200;
4181 try std.testing.expectError(error.SockPathTooLong, resolveHost(alloc, long, null, 30_000));
4182 }
4183
4184 test "resolveHost: an ssh host is polled by a recipe that cannot prompt and cannot narrate" {
4185 var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
4186 defer arena.deinit();
4187 const alloc = arena.allocator();
4188
4189 // Two spellings, because the poll target is built per host and a shared
4190 // one would point every stripe at the first host's ssh line.
4191 for ([_][]const u8{ "box", "user@gate" }) |spelling| {
4192 const spec = try resolveHost(alloc, spelling, null, 30_000);
4193 try std.testing.expect(std.mem.indexOf(u8, spec.poll_target.hand.ssh_cmd, "BatchMode=yes") != null);
4194 try std.testing.expectEqualStrings(spelling, spec.poll_target.hand.host);
4195 // The attach the user sees must still be able to ask for a password.
4196 try std.testing.expect(std.mem.indexOf(u8, spec.target.hand.ssh_cmd, "BatchMode") == null);
4197 // Neither door narrates a fallback: both are dialled under a wall
4198 // that owns the alternate screen.
4199 try std.testing.expect(!spec.target.hand.asked);
4200 try std.testing.expect(!spec.poll_target.hand.asked);
4201 }
4202
4203 // A transport that cannot prompt has one target, not a copy of one.
4204 const s = try resolveHost(alloc, "--sock /tmp/a.sock", null, 30_000);
4205 try std.testing.expectEqualStrings(s.target.sock, s.poll_target.sock);
4206 }
4207
4208 test "pollDelayMs: a poll that cost an ssh login is asked ten times less often" {
4209 // The whole point of the number: a pipe-answered poll spawned `ssh`,
4210 // read the announce and killed it — a remote auth log line per cycle.
4211 try std.testing.expectEqual(@as(u64, 10_000), wall_host.pollDelayMs(.pipe));
4212 try std.testing.expectEqual(@as(u64, 1_000), wall_host.pollDelayMs(.fd));
4213 try std.testing.expectEqual(@as(u64, 1_000), wall_host.pollDelayMs(.quic));
4214 }
4215
4216 test "endAction: on a terminal the last tile's exit leaves an empty wall, not an exit" {
4217 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = true };
4218 var tiles: [3]Tile = undefined;
4219 const one = [_]bool{ true, false, false };
4220
4221 // `x` ends a session, never the program: the wall stays up with the
4222 // picker over it, and the machines are still there to birth on. There
4223 // is no tile left to hand the focus to, which is what `back` says.
4224 endBench(&tiles, &shared, 0, .exited, 7, null);
4225 try std.testing.expectEqual(
4226 EndAction{ .vanish = .{ .back = null, .msg = null } },
4227 endAction(&tiles, &one, 3, 0, true, true),
4228 );
4229
4230 // The piped half is unchanged and load-bearing: a wall of one with no
4231 // keyboard is what a script runs, and it exits with the shell's code.
4232 endBench(&tiles, &shared, 0, .exited, 7, null);
4233 try std.testing.expectEqual(
4234 EndAction{ .finish = .{ .code = 7, .msg = null } },
4235 endAction(&tiles, &one, 3, 0, true, false),
4236 );
4237 }
4238
4239 test "endAction: a birth the picker made into a standing wall is refused ON it, not out of mux" {
4240 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = true };
4241 var tiles: [3]Tile = undefined;
4242 const one = [_]bool{ true, false, false };
4243
4244 // The headline path: the wall's last session ends, the picker opens
4245 // itself, Enter lands on a daemon that is full. There was no anchor to
4246 // hand the focus back to, so `born_from` is null — and the ENTRY tile
4247 // carries null for the opposite reason, which is why the flag exists.
4248 endBench(&tiles, &shared, 0, .refused, 0, null);
4249 tiles[0].keeps_wall = true;
4250 try std.testing.expectEqual(
4251 EndAction{ .vanish = .{ .back = null, .msg = "mux: cannot create a new session (daemon full?)" } },
4252 endAction(&tiles, &one, 3, 0, true, true),
4253 );
4254
4255 // A box that never answered: the picker advertises `unreachable` rows
4256 // as a place to press Enter, so this is a designed path, not a fault.
4257 endBench(&tiles, &shared, 0, .lost, 0, null);
4258 tiles[0].keeps_wall = true;
4259 try std.testing.expectEqual(
4260 EndAction{ .vanish = .{ .back = null, .msg = "mux: cannot create a new session (daemon unreachable)" } },
4261 endAction(&tiles, &one, 3, 0, true, true),
4262 );
4263
4264 // Nothing refused anything here, and the wall still stands.
4265 endBench(&tiles, &shared, 0, .no_thread, 0, null);
4266 tiles[0].keeps_wall = true;
4267 try std.testing.expectEqual(
4268 EndAction{ .vanish = .{ .back = null, .msg = "mux: could not start a thread for this session" } },
4269 endAction(&tiles, &one, 3, 0, true, true),
4270 );
4271
4272 // The entry tile of `mux TARGET` is the wall, and its refusal is mux's:
4273 // a script reads the code.
4274 endBench(&tiles, &shared, 0, .refused, 0, null);
4275 try std.testing.expectEqual(
4276 EndAction{ .finish = .{ .code = 1, .msg = "mux: attach refused or no state received (session full?)" } },
4277 endAction(&tiles, &one, 3, 0, true, true),
4278 );
4279
4280 // ...and a piped `mux` has no wall to leave standing, flag or no flag.
4281 endBench(&tiles, &shared, 0, .refused, 0, null);
4282 tiles[0].keeps_wall = true;
4283 try std.testing.expectEqual(
4284 EndAction{ .finish = .{ .code = 1, .msg = "mux: attach refused or no state received (session full?)" } },
4285 endAction(&tiles, &one, 3, 0, true, false),
4286 );
4287 }
4288
4289 test "endAction: the only tile's ending is mux's, and so is any ending nobody can leave" {
4290 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
4291 var tiles: [3]Tile = undefined;
4292 const one = [_]bool{ true, false, false };
4293 const two = [_]bool{ true, true, false };
4294
4295 // One tile, shell exited: the code is mux's, which is what keeps `mux`
4296 // something you can put in a script.
4297 endBench(&tiles, &shared, 0, .exited, 7, null);
4298 try std.testing.expectEqual(
4299 EndAction{ .finish = .{ .code = 7, .msg = null } },
4300 endAction(&tiles, &one, 3, 0, true, false),
4301 );
4302
4303 // Two tiles: the exit vanishes — a dead stripe for a cleanly exited
4304 // shell is noise — and the wall re-cuts onto the survivor.
4305 endBench(&tiles, &shared, 0, .exited, 7, null);
4306 try std.testing.expectEqual(
4307 EndAction{ .vanish = .{ .back = 1, .msg = null } },
4308 endAction(&tiles, &two, 3, 0, true, false),
4309 );
4310
4311 // ...but not once stdin has closed. A piped `mux` with no keyboard
4312 // waits in poll forever with the code in hand — reproduced at RC=124
4313 // before this argument existed, which is the whole reason the rule is
4314 // stated in one pure function and not at the read that happens to
4315 // notice EOF.
4316 endBench(&tiles, &shared, 0, .exited, 7, null);
4317 try std.testing.expectEqual(
4318 EndAction{ .finish = .{ .code = 7, .msg = null } },
4319 endAction(&tiles, &two, 3, 0, false, false),
4320 );
4321
4322 // A tile the user already forgot is not company: `present`, never the
4323 // wall's length, is what "somewhere to go" counts.
4324 const one_left = [_]bool{ true, false, false };
4325 endBench(&tiles, &shared, 0, .exited, 3, null);
4326 try std.testing.expectEqual(
4327 EndAction{ .finish = .{ .code = 3, .msg = null } },
4328 endAction(&tiles, &one_left, 3, 0, true, false),
4329 );
4330 }
4331
4332 test "endAction: a refusal a chord earned goes back where the chord was typed" {
4333 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
4334 var tiles: [3]Tile = undefined;
4335 const two = [_]bool{ true, true, false };
4336
4337 // The plain client's `.refused` recovery: the daemon would not create
4338 // the session, and the chord-born tile leaves with no trace — the
4339 // sentence is the record a script reads, the survivor the screen.
4340 endBench(&tiles, &shared, 1, .refused, 1, 0);
4341 try std.testing.expectEqual(
4342 EndAction{ .vanish = .{ .back = 0, .msg = "mux: cannot create a new session (daemon full?)" } },
4343 endAction(&tiles, &two, 3, 1, true, false),
4344 );
4345
4346 // It outranks the closed stdin, because vanishing lands on a live
4347 // SESSION rather than on nothing: whatever ends that one ends the run.
4348 endBench(&tiles, &shared, 1, .refused, 1, 0);
4349 try std.testing.expectEqual(
4350 EndAction{ .vanish = .{ .back = 0, .msg = "mux: cannot create a new session (daemon full?)" } },
4351 endAction(&tiles, &two, 3, 1, false, false),
4352 );
4353
4354 // A daemon that never answered is not a daemon that answered and said
4355 // no. `x` on the last LOCAL session ends the local daemon, so a wall
4356 // whose own machine has gone is exactly how this arrives — and "full?"
4357 // would send the user hunting for sessions that are not there.
4358 endBench(&tiles, &shared, 1, .lost, 1, 0);
4359 try std.testing.expectEqual(
4360 EndAction{ .vanish = .{ .back = 0, .msg = "mux: cannot create a new session (daemon unreachable)" } },
4361 endAction(&tiles, &two, 3, 1, true, false),
4362 );
4363
4364 // ...but a chord-born tile that HAD its session and lost the link later
4365 // is not a failed creation at all: it keeps its rect and narrates, like
4366 // any other lost tile.
4367 endBench(&tiles, &shared, 1, .lost, 1, 0);
4368 tiles[1].ever_up.store(true, .release);
4369 try std.testing.expectEqual(
4370 EndAction{ .refocus = 0 },
4371 endAction(&tiles, &two, 3, 1, true, false),
4372 );
4373
4374 // A refusal with nowhere to fall back to is an ending like any other,
4375 // and says the thing the plain client said.
4376 endBench(&tiles, &shared, 0, .refused, 1, null);
4377 const one = [_]bool{ true, false, false };
4378 const out = endAction(&tiles, &one, 3, 0, true, false);
4379 try std.testing.expectEqual(@as(u8, 1), out.finish.code);
4380 try std.testing.expectEqualStrings(
4381 "mux: attach refused or no state received (session full?)",
4382 out.finish.msg.?,
4383 );
4384
4385 // ...and so is one whose fall-back tile the user forgot meanwhile.
4386 endBench(&tiles, &shared, 1, .refused, 1, 0);
4387 const gone_back = [_]bool{ false, true, false };
4388 try std.testing.expectEqual(
4389 @as(u8, 1),
4390 endAction(&tiles, &gone_back, 3, 1, true, false).finish.code,
4391 );
4392 }
4393
4394 test "endAction: an exited session's tile vanishes; a lost one narrates" {
4395 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
4396 var tiles: [3]Tile = undefined;
4397 const two = [_]bool{ true, true, false };
4398
4399 // A clean exit is noise once it is over: the tile leaves, the wall
4400 // re-cuts, and nothing is said.
4401 endBench(&tiles, &shared, 0, .exited, 7, null);
4402 try std.testing.expectEqual(
4403 EndAction{ .vanish = .{ .back = 1, .msg = null } },
4404 endAction(&tiles, &two, 3, 0, true, false),
4405 );
4406
4407 // A lost link can heal: the tile keeps its rect and its bar, and the
4408 // focus steps to the next present tile rather than the tile leaving.
4409 endBench(&tiles, &shared, 0, .lost, 1, null);
4410 try std.testing.expectEqual(
4411 EndAction{ .refocus = 1 },
4412 endAction(&tiles, &two, 3, 0, true, false),
4413 );
4414
4415 // The last present tile exiting is still mux's own ending, with the
4416 // shell's code — `mux` in a script is the thing that ran the shell.
4417 endBench(&tiles, &shared, 0, .exited, 3, null);
4418 const one = [_]bool{ true, false, false };
4419 try std.testing.expectEqual(
4420 EndAction{ .finish = .{ .code = 3, .msg = null } },
4421 endAction(&tiles, &one, 3, 0, true, false),
4422 );
4423 }
4424
4425 test "endAction: a vanishing tile hands focus to a live neighbour, not a corpse" {
4426 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
4427 var tiles: [3]Tile = undefined;
4428 const two = [_]bool{ true, true, false };
4429
4430 // Tile 0 (focused) has just exited — its pump is dead. Tile 1 is
4431 // present but dead (pump ended, narrating a lost link). With no live
4432 // neighbour, the back-target falls back to the dead tile (the wall
4433 // still shows it), but with a live third tile, focus lands there.
4434 endBench(&tiles, &shared, 0, .exited, 7, null);
4435 tiles[0].alive.store(false, .release);
4436 tiles[1].alive.store(false, .release);
4437 tiles[1].end.store(@intFromEnum(EndReason.lost), .release);
4438 tiles[1].end_seen = true;
4439 // Two tiles, both dead: fallback to the present-only step.
4440 const out_dead = endAction(&tiles, &two, 3, 0, true, false);
4441 try std.testing.expect(out_dead == .vanish);
4442 try std.testing.expectEqual(@as(usize, 1), out_dead.vanish.back);
4443
4444 // Three tiles: 0 dead ending, 1 dead narrating, 2 alive. Focus must
4445 // land on tile 2, the live neighbour — never the dead tile 1.
4446 const three = [_]bool{ true, true, true };
4447 endBench(&tiles, &shared, 0, .exited, 7, null);
4448 tiles[0].alive.store(false, .release);
4449 tiles[1].alive.store(false, .release);
4450 tiles[1].end.store(@intFromEnum(EndReason.lost), .release);
4451 tiles[1].end_seen = true;
4452 const out_live = endAction(&tiles, &three, 3, 0, true, false);
4453 try std.testing.expect(out_live == .vanish);
4454 try std.testing.expectEqual(@as(usize, 2), out_live.vanish.back);
4455 }
4456
4457 test "viewRows: a one-tile wall keeps every row, a multi-tile wall loses the label bar" {
4458 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
4459 // A one-tile wall: no label bar, the tile claims every row.
4460 shared.label_rows = 0;
4461 var t0 = Tile{
4462 .r = .{ .target = .{ .sock = "/tmp/x" }, .label = "x", .session = "" },
4463 .rect = .{ .top = 0, .left = 0, .rows = 24, .cols = 80 },
4464 .shared = &shared,
4465 .idx = 0,
4466 .wake_r = -1,
4467 .wake_w = -1,
4468 };
4469 try std.testing.expectEqual(@as(u16, 24), t0.viewRows());
4470
4471 // A two-tile wall: each tile loses one row to the label bar.
4472 shared.label_rows = 1;
4473 var t1 = Tile{
4474 .r = .{ .target = .{ .sock = "/tmp/x" }, .label = "x", .session = "" },
4475 .rect = .{ .top = 0, .left = 0, .rows = 12, .cols = 80 },
4476 .shared = &shared,
4477 .idx = 0,
4478 .wake_r = -1,
4479 .wake_w = -1,
4480 };
4481 var t2 = Tile{
4482 .r = .{ .target = .{ .sock = "/tmp/y" }, .label = "y", .session = "" },
4483 .rect = .{ .top = 12, .left = 0, .rows = 12, .cols = 80 },
4484 .shared = &shared,
4485 .idx = 1,
4486 .wake_r = -1,
4487 .wake_w = -1,
4488 };
4489 try std.testing.expectEqual(@as(u16, 11), t1.viewRows());
4490 try std.testing.expectEqual(@as(u16, 11), t2.viewRows());
4491 }
4492
4493 test "relayout sets resize_pending on every live tile" {
4494 const alloc = std.testing.allocator;
4495 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
4496 shared.tree = layout.Tree.init(alloc);
4497 shared.flat_alloc = alloc;
4498 defer shared.tree.deinit();
4499 defer if (shared.last_flat) |*f| f.deinit(alloc);
4500 defer if (shared.base_flat) |*f| f.deinit(alloc);
4501 try shared.tree.addFirst(0);
4502 try shared.tree.insert(0, 1);
4503 const tiles = try alloc.alloc(Tile, 2);
4504 defer alloc.free(tiles);
4505 const present = try alloc.alloc(bool, 2);
4506 defer alloc.free(present);
4507 present[0] = true;
4508 present[1] = true;
4509 for (tiles, 0..) |*t, i| {
4510 t.* = Tile{
4511 .r = .{ .target = .{ .sock = "/tmp/x" }, .label = "x", .session = "" },
4512 .rect = .{ .top = 0, .left = 0, .rows = 24, .cols = 80 },
4513 .shared = &shared,
4514 .idx = i,
4515 .wake_r = -1,
4516 .wake_w = -1,
4517 };
4518 }
4519 wall_layout.relayout(alloc, tiles, present, &shared, 0);
4520 // Two tiles: a label bar appears.
4521 try std.testing.expectEqual(@as(u8, 1), shared.label_rows);
4522 // Every tile is doorbelled: its pump sends the new rect.
4523 try std.testing.expect(tiles[0].resize_pending);
4524 try std.testing.expect(tiles[1].resize_pending);
4525 }
4526
4527 // A pump, reduced to the one thing this test judges: it takes passes, and
4528 // remembers the content size the pass told it to send.
4529 const ResizeWitness = struct {
4530 t: *Tile,
4531 stop: std.atomic.Value(bool) = std.atomic.Value(bool).init(false),
4532 sent: std.atomic.Value(u32) = std.atomic.Value(u32).init(0),
4533 passes: std.atomic.Value(u64) = std.atomic.Value(u64).init(0),
4534
4535 fn run(w: *ResizeWitness) void {
4536 while (!w.stop.load(.acquire)) {
4537 const p = wall_pump.takePass(w.t);
4538 if (p.resize) w.sent.store(pack(p.cols, p.rows -| p.label_rows), .release);
4539 _ = w.passes.fetchAdd(1, .release);
4540 }
4541 }
4542
4543 fn pack(cols: u16, rows: u16) u32 {
4544 return (@as(u32, cols) << 16) | @as(u32, rows);
4545 }
4546 };
4547
4548 test "a relayout landing mid-pass is still sent" {
4549 const alloc = std.testing.allocator;
4550 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
4551 shared.tree = layout.Tree.init(alloc);
4552 shared.flat_alloc = alloc;
4553 defer shared.tree.deinit();
4554 defer if (shared.last_flat) |*f| f.deinit(alloc);
4555 defer if (shared.base_flat) |*f| f.deinit(alloc);
4556 try shared.tree.addFirst(0);
4557 try shared.tree.insert(0, 1);
4558 const tiles = try alloc.alloc(Tile, 2);
4559 defer alloc.free(tiles);
4560 const present = [_]bool{ true, true };
4561 for (tiles, 0..) |*t, i| {
4562 t.* = Tile{
4563 .r = .{ .target = .{ .sock = "/tmp/x" }, .label = "x", .session = "" },
4564 .rect = .{ .top = 0, .left = 0, .rows = 24, .cols = 80 },
4565 .shared = &shared,
4566 .idx = i,
4567 .wake_r = -1,
4568 .wake_w = -1,
4569 };
4570 }
4571
4572 var w = ResizeWitness{ .t = &tiles[0] };
4573 const th = try std.Thread.spawn(.{}, ResizeWitness.run, .{&w});
4574 defer {
4575 w.stop.store(true, .release);
4576 th.join();
4577 }
4578 // The dimension this fixture varies is TIME: the wall is re-cut while a
4579 // pump is mid-pass, over and over, so a relayout is free to land at any
4580 // point inside one. Both heights clear `wallFloors(2)`, so every cut
4581 // really does move the rect.
4582 const heights = [_]u16{ 24, 22, 24, 23 };
4583 var i: usize = 0;
4584 while (i < 400) : (i += 1) {
4585 shared.paint_mu.lock();
4586 shared.size = .{ .cols = 80, .rows = heights[i % heights.len] };
4587 shared.paint_mu.unlock();
4588 wall_layout.relayout(alloc, tiles, &present, &shared, 0);
4589
4590 // Quiesce: the pump owes nothing, and the pass that took the last
4591 // doorbell has finished. Only then is "what the daemon holds" a
4592 // settled question.
4593 while (true) {
4594 shared.paint_mu.lock();
4595 const owed = tiles[0].resize_pending;
4596 shared.paint_mu.unlock();
4597 if (!owed) break;
4598 }
4599 const n = w.passes.load(.acquire);
4600 while (w.passes.load(.acquire) < n + 1) {}
4601
4602 shared.paint_mu.lock();
4603 const want = ResizeWitness.pack(tiles[0].rect.cols, tiles[0].rect.rows -| shared.label_rows);
4604 shared.paint_mu.unlock();
4605 // The claim: a quiet wall and a quiet pump agree on the grid. A
4606 // doorbell consumed apart from the rect it describes breaks this —
4607 // the pump sent the rect it snapshotted BEFORE the re-cut, cleared
4608 // the flag, and nothing re-sends.
4609 try std.testing.expectEqual(want, w.sent.load(.acquire));
4610 }
4611 }
4612
4613 // The terminal, as an oracle. The wall's bytes are replayed into an engine
4614 // and the GRID is judged: a bar painted at a row the terminal no longer has
4615 // looks fine in the bytes — the terminal clamps the CUP onto its last row —
4616 // and lands on top of somebody else's content in the grid.
4617 const WallScreen = struct {
4618 eng: *Engine,
4619 r: std.posix.fd_t,
4620 w: std.posix.fd_t,
4621
4622 fn init(alloc: std.mem.Allocator, cols: u16, rows: u16) !WallScreen {
4623 const p = try std.posix.pipe2(.{ .NONBLOCK = true });
4624 return .{
4625 .eng = try Engine.init(alloc, .{ .cols = cols, .rows = rows }),
4626 .r = p[0],
4627 .w = p[1],
4628 };
4629 }
4630
4631 fn deinit(self: *WallScreen) void {
4632 self.eng.deinit();
4633 std.posix.close(self.r);
4634 std.posix.close(self.w);
4635 }
4636
4637 fn drain(self: *WallScreen) void {
4638 var buf: [4096]u8 = undefined;
4639 while (std.posix.read(self.r, &buf)) |n| {
4640 if (n == 0) break;
4641 self.eng.feed(buf[0..n]);
4642 } else |_| {}
4643 }
4644
4645 fn line(text: []const u8, n: u16) ?[]const u8 {
4646 var it = std.mem.splitScalar(u8, text, '\n');
4647 var i: u16 = 0;
4648 while (it.next()) |l| : (i += 1) if (i == n) return l;
4649 return null;
4650 }
4651 };
4652
4653 test "a terminal too small for the cut falls back to the focused pane, and grows back" {
4654 const alloc = std.testing.allocator;
4655 const n = 7;
4656 var screen = try WallScreen.init(alloc, 80, 24);
4657 defer screen.deinit();
4658 var shared = Shared{ .out_fd = screen.w, .size = .{ .cols = 80, .rows = 24 }, .is_tty = true };
4659 shared.tree = layout.Tree.init(alloc);
4660 shared.flat_alloc = alloc;
4661 defer shared.tree.deinit();
4662 defer if (shared.last_flat) |*f| f.deinit(alloc);
4663 defer if (shared.base_flat) |*f| f.deinit(alloc);
4664 try shared.tree.addFirst(0);
4665 var k: u8 = 1;
4666 while (k < n) : (k += 1) try shared.tree.insert(k - 1, k);
4667 const tiles = try alloc.alloc(Tile, n);
4668 defer alloc.free(tiles);
4669 const present = try alloc.alloc(bool, n);
4670 defer alloc.free(present);
4671 var labels: [n][2]u8 = undefined;
4672 for (tiles, 0..) |*t, i| {
4673 present[i] = true;
4674 labels[i] = .{ 't', '0' + @as(u8, @intCast(i)) };
4675 t.* = Tile{
4676 .r = .{ .target = .{ .sock = "/tmp/x" }, .label = &labels[i], .session = "" },
4677 .rect = .{ .top = 0, .left = 0, .rows = 24, .cols = 80 },
4678 .shared = &shared,
4679 .idx = i,
4680 .wake_r = -1,
4681 .wake_w = -1,
4682 };
4683 }
4684 // One tile whose pump has ended: the keyboard is the only thread left
4685 // that can paint its bar, so it is the tile that proves a HIDDEN pane
4686 // gets no bar either.
4687 tiles[n - 1].alive.store(false, .release);
4688
4689 // The dimension this fixture varies is the terminal's height — the one
4690 // the wall is cut against. 24 rows hold seven panes under their bars;
4691 // 20 do not, and `flatten` says so; and the wall has to come back.
4692 for ([_]u16{ 24, 20, 24 }) |rows| {
4693 try screen.eng.resize(80, rows);
4694 shared.paint_mu.lock();
4695 shared.size = .{ .cols = 80, .rows = rows };
4696 shared.paint_mu.unlock();
4697 wall_layout.relayout(alloc, tiles, present, &shared, 0);
4698 // What the pumps do with the generation bump: repaint their bars.
4699 for (tiles, present) |*t, p| if (p) paintLabel(t, .up);
4700 screen.drain();
4701 const dump = try screen.eng.dumpPlain(alloc);
4702 defer alloc.free(dump);
4703
4704 // No tile ever claims a row the terminal does not have. A SIGWINCH
4705 // is not an operation and cannot be refused: doing nothing leaves
4706 // every rect pointing past the bottom of the screen.
4707 for (tiles, present) |*t, p| {
4708 if (!p) continue;
4709 try std.testing.expect(t.rect.top + t.rect.rows <= rows);
4710 try std.testing.expect(t.rect.left + t.rect.cols <= 80);
4711 }
4712 if (rows == 20) {
4713 // The degrade: the focused pane whole, every other pane at 0x0
4714 // — a rect that paints nothing and claims no size — and the
4715 // tree untouched underneath.
4716 try std.testing.expectEqual(@as(u16, 0), shared.label_rows);
4717 try std.testing.expectEqual(@as(u16, 20), tiles[0].rect.rows);
4718 try std.testing.expectEqual(@as(u16, 80), tiles[0].rect.cols);
4719 try std.testing.expectEqual(@as(u16, 0), tiles[0].rect.top);
4720 for (tiles[1..]) |*t| try std.testing.expectEqual(@as(u16, 0), t.rect.rows);
4721 // One pane, so no bars: every row of the grid the wall itself
4722 // wrote is blank, hidden tiles included.
4723 var it = std.mem.splitScalar(u8, dump, '\n');
4724 while (it.next()) |l|
4725 try std.testing.expectEqual(@as(usize, 0), std.mem.trim(u8, l, " ").len);
4726 } else {
4727 try std.testing.expectEqual(@as(u16, 1), shared.label_rows);
4728 // Seven bars, each on the first row of the pane it names.
4729 for (tiles) |*t| {
4730 const l = WallScreen.line(dump, t.rect.top) orelse return error.NoSuchRow;
4731 try std.testing.expect(std.mem.indexOf(u8, l, t.r.label) != null);
4732 }
4733 }
4734 }
4735 }
4736
4737 test "fullscreen gives the focused tile the whole terminal and hides the rest" {
4738 const alloc = std.testing.allocator;
4739 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
4740 shared.tree = layout.Tree.init(alloc);
4741 shared.flat_alloc = alloc;
4742 defer shared.tree.deinit();
4743 defer if (shared.last_flat) |*f| f.deinit(alloc);
4744 defer if (shared.base_flat) |*f| f.deinit(alloc);
4745 try shared.tree.addFirst(0);
4746 try shared.tree.insert(0, 1);
4747 const tiles = try alloc.alloc(Tile, 2);
4748 defer alloc.free(tiles);
4749 const present = [_]bool{ true, true };
4750 for (tiles, 0..) |*t, i| {
4751 t.* = Tile{
4752 .r = .{ .target = .{ .sock = "/tmp/x" }, .label = "x", .session = "" },
4753 .rect = .{ .top = 0, .left = 0, .rows = 24, .cols = 80 },
4754 .shared = &shared,
4755 .idx = i,
4756 .wake_r = -1,
4757 .wake_w = -1,
4758 };
4759 }
4760 shared.fullscreen = true;
4761 wall_layout.relayout(alloc, tiles, &present, &shared, 0);
4762 // No label bar: the fullscreened pane is a one-tile wall.
4763 try std.testing.expectEqual(@as(u8, 0), shared.label_rows);
4764 // Tile 0 gets the whole terminal; tile 1 gets nothing.
4765 try std.testing.expectEqual(@as(u16, 24), tiles[0].rect.rows);
4766 try std.testing.expectEqual(@as(u16, 80), tiles[0].rect.cols);
4767 try std.testing.expectEqual(@as(u16, 0), tiles[1].rect.rows);
4768 try std.testing.expectEqual(@as(u16, 0), tiles[1].rect.cols);
4769 // The base flat is kept for neighbor.
4770 try std.testing.expect(shared.base_flat != null);
4771 }
4772
4773 test "toggle off fullscreen restores the real rects" {
4774 const alloc = std.testing.allocator;
4775 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
4776 shared.tree = layout.Tree.init(alloc);
4777 shared.flat_alloc = alloc;
4778 defer shared.tree.deinit();
4779 defer if (shared.last_flat) |*f| f.deinit(alloc);
4780 defer if (shared.base_flat) |*f| f.deinit(alloc);
4781 try shared.tree.addFirst(0);
4782 try shared.tree.insert(0, 1);
4783 const tiles = try alloc.alloc(Tile, 2);
4784 defer alloc.free(tiles);
4785 const present = [_]bool{ true, true };
4786 for (tiles, 0..) |*t, i| {
4787 t.* = Tile{
4788 .r = .{ .target = .{ .sock = "/tmp/x" }, .label = "x", .session = "" },
4789 .rect = .{ .top = 0, .left = 0, .rows = 24, .cols = 80 },
4790 .shared = &shared,
4791 .idx = i,
4792 .wake_r = -1,
4793 .wake_w = -1,
4794 };
4795 }
4796 shared.fullscreen = true;
4797 wall_layout.relayout(alloc, tiles, &present, &shared, 0);
4798 shared.fullscreen = false;
4799 wall_layout.relayout(alloc, tiles, &present, &shared, 0);
4800 // Two tiles again: label bar back, both have real rects (stacked:
4801 // full width, half height each).
4802 try std.testing.expectEqual(@as(u8, 1), shared.label_rows);
4803 try std.testing.expectEqual(@as(u16, 80), tiles[0].rect.cols);
4804 try std.testing.expectEqual(@as(u16, 80), tiles[1].rect.cols);
4805 try std.testing.expect(tiles[0].rect.rows < 24);
4806 try std.testing.expect(tiles[1].rect.rows > 0);
4807 }
4808
4809 test "focus_dir while fullscreened follows the focus" {
4810 const alloc = std.testing.allocator;
4811 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
4812 shared.tree = layout.Tree.init(alloc);
4813 shared.flat_alloc = alloc;
4814 defer shared.tree.deinit();
4815 defer if (shared.last_flat) |*f| f.deinit(alloc);
4816 defer if (shared.base_flat) |*f| f.deinit(alloc);
4817 try shared.tree.addFirst(0);
4818 try shared.tree.insert(0, 1);
4819 const tiles = try alloc.alloc(Tile, 2);
4820 defer alloc.free(tiles);
4821 const present = [_]bool{ true, true };
4822 for (tiles, 0..) |*t, i| {
4823 t.* = Tile{
4824 .r = .{ .target = .{ .sock = "/tmp/x" }, .label = "x", .session = "" },
4825 .rect = .{ .top = 0, .left = 0, .rows = 24, .cols = 80 },
4826 .shared = &shared,
4827 .idx = i,
4828 .wake_r = -1,
4829 .wake_w = -1,
4830 };
4831 }
4832 shared.fullscreen = true;
4833 wall_layout.relayout(alloc, tiles, &present, &shared, 0);
4834 // Move focus to tile 1 while fullscreened.
4835 focusAnswer(alloc, tiles, &present, &shared, false, 1);
4836 // The full rect followed: tile 1 now owns the terminal.
4837 try std.testing.expectEqual(@as(u16, 24), tiles[1].rect.rows);
4838 try std.testing.expectEqual(@as(u16, 80), tiles[1].rect.cols);
4839 try std.testing.expectEqual(@as(u16, 0), tiles[0].rect.rows);
4840 try std.testing.expectEqual(@as(usize, 1), shared.sel);
4841 }
4842
4843 test "resize: l grows the focused pane, h shrinks it" {
4844 // Spec: h shrinks width, l grows width. layout.resize always makes
4845 // the focus GAIN cells, so shrink = grow a neighbor at focus's
4846 // expense. Two beside panes at 80x24 (floors {3,2}): equal split is
4847 // 39/40 (rail takes 1). l moves the rail right; h moves it left.
4848 const alloc = std.testing.allocator;
4849 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
4850 shared.tree = layout.Tree.init(alloc);
4851 shared.flat_alloc = alloc;
4852 defer shared.tree.deinit();
4853 defer if (shared.last_flat) |*f| f.deinit(alloc);
4854 defer if (shared.base_flat) |*f| f.deinit(alloc);
4855 try shared.tree.addFirst(0);
4856 try shared.tree.splitRight(0, 1);
4857 const tiles = try alloc.alloc(Tile, 2);
4858 defer alloc.free(tiles);
4859 const present = [_]bool{ true, true };
4860 for (tiles, 0..) |*t, i| {
4861 t.* = Tile{
4862 .r = .{ .target = .{ .sock = "/tmp/x" }, .label = "x", .session = "" },
4863 .rect = .{ .top = 0, .left = 0, .rows = 24, .cols = 80 },
4864 .shared = &shared,
4865 .idx = i,
4866 .wake_r = -1,
4867 .wake_w = -1,
4868 };
4869 }
4870 wall_layout.relayout(alloc, tiles, &present, &shared, 0);
4871 const before = tiles[0].rect.cols;
4872 // l (grow width): focus 0 gains from its right sibling.
4873 try std.testing.expect(wall_layout.doResize(alloc, tiles, &present, &shared, 0, .right));
4874 try std.testing.expect(tiles[0].rect.cols > before);
4875 // h (shrink width): the right neighbor gains a cell back from focus.
4876 try std.testing.expect(wall_layout.doResize(alloc, tiles, &present, &shared, 0, .left));
4877 try std.testing.expectEqual(before, tiles[0].rect.cols);
4878 }
4879
4880 test "resize: j grows height, k shrinks height" {
4881 const alloc = std.testing.allocator;
4882 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
4883 shared.tree = layout.Tree.init(alloc);
4884 shared.flat_alloc = alloc;
4885 defer shared.tree.deinit();
4886 defer if (shared.last_flat) |*f| f.deinit(alloc);
4887 defer if (shared.base_flat) |*f| f.deinit(alloc);
4888 try shared.tree.addFirst(0);
4889 try shared.tree.splitBelow(0, 1);
4890 const tiles = try alloc.alloc(Tile, 2);
4891 defer alloc.free(tiles);
4892 const present = [_]bool{ true, true };
4893 for (tiles, 0..) |*t, i| {
4894 t.* = Tile{
4895 .r = .{ .target = .{ .sock = "/tmp/x" }, .label = "x", .session = "" },
4896 .rect = .{ .top = 0, .left = 0, .rows = 24, .cols = 80 },
4897 .shared = &shared,
4898 .idx = i,
4899 .wake_r = -1,
4900 .wake_w = -1,
4901 };
4902 }
4903 wall_layout.relayout(alloc, tiles, &present, &shared, 0);
4904 const before = tiles[0].rect.rows;
4905 try std.testing.expect(wall_layout.doResize(alloc, tiles, &present, &shared, 0, .down));
4906 try std.testing.expect(tiles[0].rect.rows > before);
4907 try std.testing.expect(wall_layout.doResize(alloc, tiles, &present, &shared, 0, .up));
4908 try std.testing.expectEqual(before, tiles[0].rect.rows);
4909 }
4910
4911 test "resize refuses while fullscreened" {
4912 const alloc = std.testing.allocator;
4913 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
4914 shared.tree = layout.Tree.init(alloc);
4915 shared.flat_alloc = alloc;
4916 defer shared.tree.deinit();
4917 defer if (shared.last_flat) |*f| f.deinit(alloc);
4918 defer if (shared.base_flat) |*f| f.deinit(alloc);
4919 try shared.tree.addFirst(0);
4920 try shared.tree.splitRight(0, 1);
4921 const tiles = try alloc.alloc(Tile, 2);
4922 defer alloc.free(tiles);
4923 const present = [_]bool{ true, true };
4924 for (tiles, 0..) |*t, i| {
4925 t.* = Tile{
4926 .r = .{ .target = .{ .sock = "/tmp/x" }, .label = "x", .session = "" },
4927 .rect = .{ .top = 0, .left = 0, .rows = 24, .cols = 80 },
4928 .shared = &shared,
4929 .idx = i,
4930 .wake_r = -1,
4931 .wake_w = -1,
4932 };
4933 }
4934 shared.fullscreen = true;
4935 wall_layout.relayout(alloc, tiles, &present, &shared, 0);
4936 try std.testing.expect(!wall_layout.doResize(alloc, tiles, &present, &shared, 0, .right));
4937 }
4938
4939 fn noticeBench(shared: *Shared, tiles: []Tile) void {
4940 for (tiles, 0..) |*t, i| {
4941 t.* = Tile{
4942 .r = .{ .target = .{ .sock = "/tmp/x" }, .label = "x", .session = "" },
4943 .rect = .{ .top = 0, .left = 0, .rows = 24, .cols = 80 },
4944 .shared = shared,
4945 .idx = i,
4946 .wake_r = -1,
4947 .wake_w = -1,
4948 };
4949 }
4950 }
4951
4952 test "closeNotice: a sentence the popup left behind is given a tile to land on" {
4953 const alloc = std.testing.allocator;
4954 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
4955 const tiles = try alloc.alloc(Tile, 2);
4956 defer alloc.free(tiles);
4957 noticeBench(&shared, tiles);
4958 const present = [_]bool{ true, true };
4959 // Focus on tile 1, and the forgotten host owned NEITHER tile: the focus
4960 // does not move on its own here, which is the whole of the finding.
4961 shared.sel = 1;
4962 setNotice(&shared, "[hosts file not updated: read-only file system]");
4963
4964 closeNotice(tiles, &present, &shared);
4965
4966 // Armed, not merely rung: a pump paints the notice on a CLAIM, and a
4967 // doorbell alone gives it nothing to claim for.
4968 if (!tiles[1].claim_pending.load(.acquire)) return error.NoticeHasNoClaimToRideOn;
4969 // Still there for the pump to take on that claim, whole: a keyboard
4970 // that consumed it here would paint nothing and lose the sentence.
4971 var buf: [96]u8 = undefined;
4972 try std.testing.expectEqualStrings(
4973 "[hosts file not updated: read-only file system]",
4974 takeNotice(&shared, &buf),
4975 );
4976
4977 // The control: with nothing to say, the close is the bare retry it was,
4978 // and no tile is handed a claim it did not earn.
4979 noticeBench(&shared, tiles);
4980 closeNotice(tiles, &present, &shared);
4981 try std.testing.expect(!tiles[1].claim_pending.load(.acquire));
4982 }
4983
4984 test "a pump's answer that grew the wall re-cuts it; a mere focus move does not" {
4985 const alloc = std.testing.allocator;
4986 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
4987 defer if (shared.last_flat) |*f| f.deinit(shared.flat_alloc);
4988 defer if (shared.base_flat) |*f| f.deinit(shared.flat_alloc);
4989 const tiles = try alloc.alloc(Tile, 2);
4990 defer alloc.free(tiles);
4991 const present = try alloc.alloc(bool, 2);
4992 defer alloc.free(present);
4993 present[0] = true;
4994 present[1] = true;
4995 for (tiles, 0..) |*t, i| {
4996 t.* = Tile{
4997 .r = .{ .target = .{ .sock = "/tmp/x" }, .label = "x", .session = "" },
4998 .rect = .{ .top = 0, .left = 0, .rows = 24, .cols = 80 },
4999 .shared = &shared,
5000 .idx = i,
5001 .wake_r = -1,
5002 .wake_w = -1,
5003 };
5004 }
5005 // Growth: without the re-cut the new tile sits on placeholder geometry
5006 // and the old tile still owns the whole terminal — the screen keeps
5007 // showing tile 0 while the keyboard types into tile 1's rows.
5008 focusAnswer(alloc, tiles, present, &shared, true, 1);
5009 try std.testing.expectEqual(@as(u8, 1), shared.label_rows);
5010 try std.testing.expect(tiles[0].resize_pending);
5011 try std.testing.expect(tiles[1].resize_pending);
5012 try std.testing.expectEqual(@as(usize, 1), shared.sel);
5013 try std.testing.expect(tiles[1].claim_pending.load(.acquire));
5014 tiles[0].resize_pending = false;
5015 tiles[1].resize_pending = false;
5016 // No growth: a full clear here would flash the wall for a focus move.
5017 focusAnswer(alloc, tiles, present, &shared, false, 0);
5018 try std.testing.expect(!tiles[0].resize_pending);
5019 try std.testing.expect(!tiles[1].resize_pending);
5020 try std.testing.expectEqual(@as(usize, 0), shared.sel);
5021 try std.testing.expect(tiles[0].claim_pending.load(.acquire));
5022 }
5023
5024 test "a one-tile wall draws no label bar and paints row 1" {
5025 const alloc = std.testing.allocator;
5026 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
5027 shared.tree = layout.Tree.init(alloc);
5028 shared.flat_alloc = alloc;
5029 defer shared.tree.deinit();
5030 defer if (shared.last_flat) |*f| f.deinit(alloc);
5031 defer if (shared.base_flat) |*f| f.deinit(alloc);
5032 try shared.tree.addFirst(0);
5033 const tiles = try alloc.alloc(Tile, 1);
5034 defer alloc.free(tiles);
5035 const present = try alloc.alloc(bool, 1);
5036 defer alloc.free(present);
5037 present[0] = true;
5038 tiles[0] = Tile{
5039 .r = .{ .target = .{ .sock = "/tmp/x" }, .label = "x", .session = "" },
5040 .rect = .{ .top = 0, .left = 0, .rows = 24, .cols = 80 },
5041 .shared = &shared,
5042 .idx = 0,
5043 .wake_r = -1,
5044 .wake_w = -1,
5045 };
5046 wall_layout.relayout(alloc, tiles, present, &shared, 0);
5047 // One tile: no label bar, every row is content.
5048 try std.testing.expectEqual(@as(u8, 0), shared.label_rows);
5049 try std.testing.expectEqual(@as(u16, 24), tiles[0].viewRows());
5050 }
5051
5052 test "bare keys reach the focused tile; the prefix does not" {
5053 var f: interact.PrefixFilter = .{};
5054 // Bare keys are forwarded to the focused tile.
5055 var keys: [3]u8 = .{ 'a', 'b', 'c' };
5056 const a = f.feed(&keys);
5057 try std.testing.expectEqualStrings("abc", a.forward);
5058 try std.testing.expectEqual(interact.PrefixFilter.Action.none, a.action);
5059 // A chord consumes its key and produces no forward bytes.
5060 var chord: [2]u8 = .{ 0x1c, 'n' };
5061 const b = f.feed(&chord);
5062 try std.testing.expectEqual(@as(usize, 0), b.forward.len);
5063 try std.testing.expectEqual(interact.PrefixFilter.Action.next_session, b.action);
5064 }
5065
5066 test "focus follows a click: rectHit picks the tile under a row and column" {
5067 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
5068 shared.label_rows = 1;
5069 var tiles: [2]Tile = undefined;
5070 tiles[0] = Tile{
5071 .r = .{ .target = .{ .sock = "/tmp/x" }, .label = "x", .session = "" },
5072 .rect = .{ .top = 0, .left = 0, .rows = 12, .cols = 80 },
5073 .shared = &shared,
5074 .idx = 0,
5075 .wake_r = -1,
5076 .wake_w = -1,
5077 };
5078 tiles[1] = Tile{
5079 .r = .{ .target = .{ .sock = "/tmp/y" }, .label = "y", .session = "" },
5080 .rect = .{ .top = 12, .left = 0, .rows = 12, .cols = 80 },
5081 .shared = &shared,
5082 .idx = 1,
5083 .wake_r = -1,
5084 .wake_w = -1,
5085 };
5086 const present = [_]bool{ true, true };
5087 // A click in row 3 hits tile 0.
5088 try std.testing.expectEqual(@as(?usize, 0), rectHit(&tiles, &present, &shared, 3, 0));
5089 // A click in row 15 hits tile 1.
5090 try std.testing.expectEqual(@as(?usize, 1), rectHit(&tiles, &present, &shared, 15, 0));
5091
5092 // A beside pair: same rows, different columns. A click in the left
5093 // half hits tile 0, the right half hits tile 1, and a rail column
5094 // between them hits neither.
5095 tiles[0].rect = .{ .top = 0, .left = 0, .rows = 24, .cols = 39 };
5096 tiles[1].rect = .{ .top = 0, .left = 41, .rows = 24, .cols = 39 };
5097 try std.testing.expectEqual(@as(?usize, 0), rectHit(&tiles, &present, &shared, 5, 10));
5098 try std.testing.expectEqual(@as(?usize, 1), rectHit(&tiles, &present, &shared, 5, 50));
5099 try std.testing.expectEqual(@as(?usize, null), rectHit(&tiles, &present, &shared, 5, 40));
5100 }
5101
5102 // A transport that swallows every frame: a Core's selection request goes
5103 // nowhere, which is what lets a copy test stand up without a daemon.
5104 const SwallowTransport = struct {
5105 pub fn writeFrame(_: *SwallowTransport, _: proto.MsgType, _: []const u8) !void {}
5106 };
5107
5108 // One selection_reply payload — id, status, watermark, text — in the wire
5109 // form encodeSelectionReply writes, laid out in a caller buffer so a test
5110 // does not allocate the prefix separately.
5111 fn replyBytes(
5112 buf: []u8,
5113 id: u32,
5114 status: proto.SelectionStatus,
5115 history_rows: u32,
5116 text: []const u8,
5117 ) []const u8 {
5118 std.mem.writeInt(u32, buf[0..4], id, .little);
5119 buf[4] = @intFromEnum(status);
5120 std.mem.writeInt(u32, buf[5..9], history_rows, .little);
5121 @memcpy(buf[proto.selection_reply_prefix_len..][0..text.len], text);
5122 return buf[0 .. proto.selection_reply_prefix_len + text.len];
5123 }
5124
5125 test "a copy too big for OSC 52 is said out loud rather than dropped" {
5126 // The notice path is the whole claim. A reply that rounds to ok on the
5127 // wire but outruns OSC 52 is neither trimmed nor dropped: a half-copied
5128 // selection is worse than none, because the user finds out when they
5129 // paste it. The drag is per-tile now, on the Core the pump owns, so the
5130 // copy goes through that Core — no claim is taken, the copy reads the
5131 // drag and not the terminal's modes.
5132 const alloc = std.testing.allocator;
5133 const p = try std.posix.pipe2(.{ .NONBLOCK = true });
5134 defer std.posix.close(p[0]);
5135 defer std.posix.close(p[1]);
5136 var shared = Shared{ .out_fd = p[1], .size = .{ .cols = 80, .rows = 24 }, .is_tty = true };
5137 var t = Tile{
5138 .r = .{ .target = .{ .sock = "/s" }, .label = "a", .session = "a" },
5139 .rect = .{ .top = 0, .left = 0, .rows = 24, .cols = 80 },
5140 .shared = &shared,
5141 .idx = 0,
5142 .wake_r = -1,
5143 .wake_w = -1,
5144 };
5145 var core = try interact.Core.initSized(alloc, -1, p[1], shared.size);
5146 defer core.deinit();
5147 core.rep.history_rows = 0;
5148 core.drag.press(.{ .row = 3, .col = 4 }, .{ .tile = 0, .row = 0, .col = 4 });
5149 core.drag.motion(.{ .row = 5, .col = 9 }, .{ .tile = 0, .row = 2, .col = 9 });
5150 _ = core.drag.release();
5151 try std.testing.expect(core.drag.range() != null);
5152 var tr: SwallowTransport = .{};
5153 const held = core.drag.range() orelse return error.NoDrag;
5154 try core.requestSelection(&tr, held);
5155
5156 // Base64 of this length overruns clipboard_base64_max: the daemon sends
5157 // it as ok, and OSC 52 cannot carry it.
5158 const fits = proto.clipboard_base64_max / 4 * 3;
5159 const big = try alloc.alloc(u8, fits + 1);
5160 defer alloc.free(big);
5161 @memset(big, 'x');
5162 const rbuf = try alloc.alloc(u8, proto.selection_reply_prefix_len + big.len);
5163 defer alloc.free(rbuf);
5164 const payload = replyBytes(rbuf, core.sel_id, .ok, 0, big);
5165
5166 wall_pump.copySelection(&t, alloc, &core, payload);
5167 var buf: [8192]u8 = undefined;
5168 const said = readAvail(p[0], &buf);
5169 try std.testing.expect(std.mem.indexOf(u8, said, "too large") != null);
5170 // Refused, not trimmed: a half-copied selection is worse than none,
5171 // because the user finds out when they paste it.
5172 try std.testing.expect(std.mem.indexOf(u8, said, "\x1b]52;") == null);
5173 }
5174
5175 test "setFocus writes the outgoing tile's release before the store that lets the next pump claim" {
5176 // The keyboard thread's half of the terminal handover, and the order is
5177 // load-bearing: two pumps are two threads, and A's release and B's claim
5178 // race if the release is not on the wire first. The outgoing pump writes
5179 // nothing (already_written), so if this stops emitting, nothing does —
5180 // and the wall reports clicks into whatever shell the user lands in.
5181 // is_tty is true because this leg is about what a focus move WRITES, and
5182 // the gate that suppresses those writes on a pipe is exactly what it
5183 // must not be testing.
5184 const p = try std.posix.pipe2(.{ .NONBLOCK = true });
5185 defer std.posix.close(p[0]);
5186 defer std.posix.close(p[1]);
5187 var shared = Shared{ .out_fd = p[1], .size = .{ .cols = 80, .rows = 24 }, .is_tty = true };
5188 var tiles: [2]Tile = undefined;
5189 tiles[0] = Tile{
5190 .r = .{ .target = .{ .sock = "/s" }, .label = "a", .session = "a" },
5191 .rect = .{ .top = 0, .left = 0, .rows = 12, .cols = 80 },
5192 .shared = &shared,
5193 .idx = 0,
5194 .wake_r = -1,
5195 .wake_w = -1,
5196 };
5197 tiles[1] = Tile{
5198 .r = .{ .target = .{ .sock = "/s" }, .label = "b", .session = "b" },
5199 .rect = .{ .top = 12, .left = 0, .rows = 12, .cols = 80 },
5200 .shared = &shared,
5201 .idx = 1,
5202 .wake_r = -1,
5203 // The claim doorbell lands on the SAME pipe as the release: the
5204 // write order on one fd is the only order a test can read, and the
5205 // release must come first.
5206 .wake_w = p[1],
5207 };
5208 shared.sel = 0;
5209 var buf: [512]u8 = undefined;
5210
5211 // A move to the SAME tile has no outgoing session to take off, so it
5212 // writes no release — a release here would unset modes the user's own
5213 // terminal may have arrived with.
5214 setFocus(&tiles, &shared, 0);
5215 try std.testing.expect(std.mem.indexOf(u8, readAvail(p[0], &buf), interact.session_release) == null);
5216
5217 // A -> B: the release is written under paint_mu, before sel is stored
5218 // and before B is doorbelled to claim. On the one pipe both share that
5219 // shows up as the release preceding the claim's wake byte.
5220 setFocus(&tiles, &shared, 1);
5221 const out = readAvail(p[0], &buf);
5222 const rel = std.mem.indexOf(u8, out, interact.session_release) orelse
5223 return error.NoReleaseOnFocusMove;
5224 const bell = std.mem.indexOfScalar(u8, out, 0) orelse
5225 return error.NoClaimDoorbell;
5226 try std.testing.expect(rel < bell);
5227 // The outgoing pump is told to release, the incoming one to claim.
5228 try std.testing.expect(tiles[0].release_pending.load(.acquire));
5229 try std.testing.expect(tiles[1].claim_pending.load(.acquire));
5230 try std.testing.expectEqual(@as(usize, 1), shared.sel);
5231 }
5232
5233 test "a relayout drops every tile's highlight, because the stripes move under it" {
5234 // The drag is per-tile now, on each pump's Core, so relayout cannot
5235 // reach it directly. It bumps repaint_gen instead, and every pump
5236 // clears its own core.drag when it sees the generation move under it —
5237 // a relayout re-cut the rect a held drag's anchor was resolved against.
5238 // The generation is the whole mechanism, so it is what this pins.
5239 const alloc = std.testing.allocator;
5240 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
5241 shared.tree = layout.Tree.init(alloc);
5242 shared.flat_alloc = alloc;
5243 defer shared.tree.deinit();
5244 defer if (shared.last_flat) |*f| f.deinit(alloc);
5245 defer if (shared.base_flat) |*f| f.deinit(alloc);
5246 try shared.tree.addFirst(0);
5247 try shared.tree.insert(0, 1);
5248 const tiles = try alloc.alloc(Tile, 2);
5249 defer alloc.free(tiles);
5250 const present = try alloc.alloc(bool, 2);
5251 defer alloc.free(present);
5252 present[0] = true;
5253 present[1] = true;
5254 for (tiles, 0..) |*t, i| {
5255 t.* = Tile{
5256 .r = .{ .target = .{ .sock = "/tmp/x" }, .label = "x", .session = "" },
5257 .rect = .{ .top = 0, .left = 0, .rows = 24, .cols = 80 },
5258 .shared = &shared,
5259 .idx = i,
5260 .wake_r = -1,
5261 .wake_w = -1,
5262 };
5263 }
5264 const before = shared.repaint_gen.load(.acquire);
5265 wall_layout.relayout(alloc, tiles, present, &shared, 0);
5266 // One bump, every tile's pump clears its drag on it: the stripes moved,
5267 // so a held drag's anchor names a rect that is somewhere else now.
5268 try std.testing.expect(shared.repaint_gen.load(.acquire) > before);
5269 }
5270
5271 test "a reconnect drops the highlight over its own tile, and only its own" {
5272 // Core.reattached clears that Core's drag — a resync renames the
5273 // absolute row space, so a highlight kept across one would invert rows
5274 // nobody selected. The drag is per-tile, so one tile's reconnect says
5275 // nothing about another's selection: the neighbour's highlight stays.
5276 const alloc = std.testing.allocator;
5277 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
5278 var tiles: [2]Tile = undefined;
5279 tiles[0] = Tile{
5280 .r = .{ .target = .{ .sock = "/s" }, .label = "a", .session = "a" },
5281 .rect = .{ .top = 0, .left = 0, .rows = 12, .cols = 80 },
5282 .shared = &shared,
5283 .idx = 0,
5284 .wake_r = -1,
5285 .wake_w = -1,
5286 };
5287 tiles[1] = Tile{
5288 .r = .{ .target = .{ .sock = "/s" }, .label = "b", .session = "b" },
5289 .rect = .{ .top = 12, .left = 0, .rows = 12, .cols = 80 },
5290 .shared = &shared,
5291 .idx = 1,
5292 .wake_r = -1,
5293 .wake_w = -1,
5294 };
5295 // Each tile's pump owns its own Core, and each Core its own drag. A
5296 // drag over tile 0 and one over tile 1, anchored in their own tiles.
5297 var core0 = try interact.Core.initSized(alloc, -1, -1, shared.size);
5298 defer core0.deinit();
5299 var core1 = try interact.Core.initSized(alloc, -1, -1, shared.size);
5300 defer core1.deinit();
5301 core0.drag.press(.{ .row = 3, .col = 4 }, .{ .tile = 0, .row = 0, .col = 4 });
5302 core0.drag.motion(.{ .row = 5, .col = 9 }, .{ .tile = 0, .row = 2, .col = 9 });
5303 core1.drag.press(.{ .row = 3, .col = 4 }, .{ .tile = 1, .row = 0, .col = 4 });
5304 core1.drag.motion(.{ .row = 5, .col = 9 }, .{ .tile = 1, .row = 2, .col = 9 });
5305 try std.testing.expectEqual(@as(?usize, 0), core0.drag.on());
5306 try std.testing.expectEqual(@as(?usize, 1), core1.drag.on());
5307
5308 // Tile 0 reconnects: its row space is about to be renamed, so its
5309 // highlight comes off. Tile 1's never went away.
5310 core0.reattached();
5311 try std.testing.expect(core0.drag.range() == null);
5312 try std.testing.expect(core0.drag.on() == null);
5313 try std.testing.expect(core1.drag.range() != null);
5314 try std.testing.expectEqual(@as(?usize, 1), core1.drag.on());
5315 }
5316
5317 test "a focus move drops what the wall's input filters are holding" {
5318 // The input-filter state that mattered — the drag — moved off the
5319 // wall's keyboard loop and onto each tile's Core. setFocus drops it the
5320 // same way relayout does: a repaint_gen bump every pump clears its
5321 // core.drag on. The wall-level prefix is deliberately KEPT, because a
5322 // chord is a wall command now and not a tile's; what stopped existing on
5323 // a focus move is the screen the drag was drawn on, not a half-typed
5324 // chord about it.
5325 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
5326 var tiles: [2]Tile = undefined;
5327 tiles[0] = Tile{
5328 .r = .{ .target = .{ .sock = "/s" }, .label = "a", .session = "a" },
5329 .rect = .{ .top = 0, .left = 0, .rows = 12, .cols = 80 },
5330 .shared = &shared,
5331 .idx = 0,
5332 .wake_r = -1,
5333 .wake_w = -1,
5334 };
5335 tiles[1] = Tile{
5336 .r = .{ .target = .{ .sock = "/s" }, .label = "b", .session = "b" },
5337 .rect = .{ .top = 12, .left = 0, .rows = 12, .cols = 80 },
5338 .shared = &shared,
5339 .idx = 1,
5340 .wake_r = -1,
5341 .wake_w = -1,
5342 };
5343 shared.sel = 0;
5344 const before = shared.repaint_gen.load(.acquire);
5345 setFocus(&tiles, &shared, 1);
5346 try std.testing.expect(shared.repaint_gen.load(.acquire) > before);
5347 }
5348
5349 test "setFocus: the marker moves between two tiles with no pump to move it" {
5350 // A refused attach and an exited session both leave a tile
5351 // whose pump has ended: repaint_gen and the doorbell reach nobody, so
5352 // the bar keeps whatever marker it was born with. Two of them and
5353 // Ctrl-\ 1 / Ctrl-\ 2 change nothing on screen; one of them beside a
5354 // live tile shows two `>`. The keyboard is the only thread left that
5355 // can paint these, which is what this pins.
5356 const p = try std.posix.pipe2(.{ .NONBLOCK = true });
5357 defer std.posix.close(p[0]);
5358 defer std.posix.close(p[1]);
5359 var shared = Shared{ .out_fd = p[1], .size = .{ .cols = 80, .rows = 24 }, .is_tty = true };
5360 shared.label_rows = 1; // two tiles draw a bar
5361 var tiles: [3]Tile = undefined;
5362 tiles[0] = Tile{
5363 .r = .{ .target = .{ .sock = "/s" }, .label = "one", .session = "a" },
5364 .rect = .{ .top = 0, .left = 0, .rows = 12, .cols = 80 },
5365 .shared = &shared,
5366 .idx = 0,
5367 .wake_r = -1,
5368 .wake_w = -1,
5369 .state = .refused,
5370 .alive = std.atomic.Value(bool).init(false),
5371 };
5372 tiles[1] = Tile{
5373 .r = .{ .target = .{ .sock = "/s" }, .label = "two", .session = "b" },
5374 .rect = .{ .top = 12, .left = 0, .rows = 12, .cols = 80 },
5375 .shared = &shared,
5376 .idx = 1,
5377 .wake_r = -1,
5378 .wake_w = -1,
5379 .state = .refused,
5380 .alive = std.atomic.Value(bool).init(false),
5381 };
5382 // Forgotten, not merely dead: `x` clears `present` and sets `gone`
5383 // together, and a bar painted into a rect the layout no longer owns
5384 // writes over a neighbour.
5385 tiles[2] = Tile{
5386 .r = .{ .target = .{ .sock = "/s" }, .label = "ghost", .session = "c" },
5387 .rect = .{ .top = 0, .left = 0, .rows = 12, .cols = 80 },
5388 .shared = &shared,
5389 .idx = 2,
5390 .wake_r = -1,
5391 .wake_w = -1,
5392 .state = .exited,
5393 .alive = std.atomic.Value(bool).init(false),
5394 .gone = std.atomic.Value(bool).init(true),
5395 };
5396 shared.sel = 0;
5397
5398 setFocus(&tiles, &shared, 1);
5399
5400 var buf: [2048]u8 = undefined;
5401 const out = readAvail(p[0], &buf);
5402 // The bar is one contiguous write (`paintLabelLocked`), so the cursor
5403 // move and the marker are adjacent bytes: matching them together is
5404 // what says the marker landed in THIS tile's bar and not a neighbour's.
5405 if (std.mem.indexOf(u8, out, "\x1b[13;1H\x1b[7m 2> two") == null)
5406 return error.DeadTileNeverGainsTheMarker;
5407 if (std.mem.indexOf(u8, out, "\x1b[1;1H\x1b[7m 1 one") == null)
5408 return error.DeadTileNeverDropsTheMarker;
5409 if (std.mem.indexOf(u8, out, "ghost") != null)
5410 return error.ForgottenTilePaintedItsBar;
5411 }
5412
5413 test "the bar's digit is the tile's chord: idx + 1, and a two-digit tile still says where it is" {
5414 // Ctrl-\ 1-9 focuses by POSITION (tiles[idx - 1]) while the bar named the
5415 // session (#0, #2...): two series that stop lining up the moment a tile is
5416 // forgotten or a session dies. The digit on the bar is the chord's, so the
5417 // two cannot diverge.
5418 const p = try std.posix.pipe2(.{ .NONBLOCK = true });
5419 defer std.posix.close(p[0]);
5420 defer std.posix.close(p[1]);
5421 var shared = Shared{ .out_fd = p[1], .size = .{ .cols = 80, .rows = 24 }, .is_tty = true };
5422 shared.label_rows = 1;
5423 shared.sel = 0;
5424 var tiles: [3]Tile = undefined;
5425 const labels = [_][]const u8{ "one", "two", "twelve" };
5426 // idx 11 with no tiles 3..10: the digit is the tile's own idx, never a
5427 // count of what is on screen. Past 9 the chord cannot reach it, but the
5428 // number still says where it sits — the chord's reach is the chord's
5429 // limit, not the bar's.
5430 const idxs = [_]usize{ 0, 1, 11 };
5431 for (&tiles, labels, idxs, 0..) |*t, label, idx, row| {
5432 t.* = Tile{
5433 .r = .{ .target = .{ .sock = "/s" }, .label = label, .session = "a" },
5434 .rect = .{ .top = @intCast(row * 8), .left = 0, .rows = 8, .cols = 80 },
5435 .shared = &shared,
5436 .idx = idx,
5437 .wake_r = -1,
5438 .wake_w = -1,
5439 .state = .up,
5440 .alive = std.atomic.Value(bool).init(false),
5441 };
5442 paintLabelLocked(t);
5443 }
5444
5445 var buf: [2048]u8 = undefined;
5446 const out = readAvail(p[0], &buf);
5447 // Cursor move and marker together, as in the setFocus pin: that is what
5448 // says the digit landed in THIS tile's bar and not a neighbour's.
5449 if (std.mem.indexOf(u8, out, "\x1b[1;1H\x1b[7m 1> one") == null)
5450 return error.FocusedBarLostItsChordDigit;
5451 if (std.mem.indexOf(u8, out, "\x1b[9;1H\x1b[7m 2 two") == null)
5452 return error.UnfocusedBarLostItsChordDigit;
5453 if (std.mem.indexOf(u8, out, "\x1b[17;1H\x1b[7m 12 twelve") == null)
5454 return error.TwoDigitTileLostItsNumber;
5455 }
5456
5457 test "a vanished tile is stepped over" {
5458 // A tile's end and a poll that no longer lists its session both refocus
5459 // through stepPresent, and a click's rectHit and the Ctrl-\ l and 1-9
5460 // chords check the same present array a vanish clears. Tiles are never
5461 // compacted — pumps hold pointers into the array — so a vanished tile is
5462 // a hole focus steps over, not a gap it fills. gone and present clear
5463 // together in `vanishTile`, so stepping on present is stepping past
5464 // gone.
5465 const mid = [_]bool{ true, false, true };
5466 try std.testing.expectEqual(@as(?usize, 2), stepPresent(&mid, 0, true));
5467 try std.testing.expectEqual(@as(?usize, 0), stepPresent(&mid, 2, true));
5468 try std.testing.expectEqual(@as(?usize, 0), stepPresent(&mid, 2, false));
5469 try std.testing.expectEqual(@as(?usize, 2), stepPresent(&mid, 0, false));
5470 // Standing on the hole (the moment after x, before the selection has
5471 // moved) still steps to a real tile.
5472 try std.testing.expectEqual(@as(?usize, 2), stepPresent(&mid, 1, true));
5473 try std.testing.expectEqual(@as(?usize, 0), stepPresent(&mid, 1, false));
5474
5475 // The last tile gone: nothing is a selection any more, and every
5476 // motion says so rather than picking a tile that is gone.
5477 const none = [_]bool{ false, false };
5478 try std.testing.expectEqual(@as(?usize, null), stepPresent(&none, 0, true));
5479 try std.testing.expectEqual(@as(?usize, null), stepPresent(&none, 0, false));
5480
5481 // One survivor is its own answer, exactly as a one-tile wall is.
5482 const one = [_]bool{ false, true, false };
5483 try std.testing.expectEqual(@as(?usize, 1), stepPresent(&one, 1, true));
5484 try std.testing.expectEqual(@as(?usize, 1), stepPresent(&one, 0, false));
5485 }
5486
5487 test "agent channels: slots fill in order, a full table refuses, and a close is announced" {
5488 // The pump's table at the size its refusal path needs. The e2e proves
5489 // one channel end to end and will never fill eight of them.
5490 var locals: [3]?AgentLocal = @splat(null);
5491
5492 // Real fds, not stand-ins: a channel here is the write end of a pipe, so
5493 // "the table closed it" is answered by the READER seeing EOF rather than
5494 // by trusting the table's own bookkeeping. Non-blocking, so a channel
5495 // that was NOT closed reports EAGAIN and fails the assertion instead of
5496 // parking the suite on a read that never returns.
5497 var peers: [4]std.posix.fd_t = undefined;
5498 var chans: [4]std.posix.fd_t = undefined;
5499 for (0..4) |i| {
5500 const p = try std.posix.pipe2(.{ .NONBLOCK = true });
5501 peers[i] = p[0];
5502 chans[i] = p[1];
5503 }
5504 defer for (peers) |fd| std.posix.close(fd);
5505
5506 for (0..3) |i| try std.testing.expectEqual(
5507 @as(?usize, i),
5508 wall_pump.storeLocal(&locals, @intCast(10 + i), chans[i]),
5509 );
5510 // The full table the pump answers with `agent_close`. The fourth fd stays
5511 // this test's to close: a refused store never took ownership of it.
5512 try std.testing.expectEqual(@as(?usize, null), wall_pump.storeLocal(&locals, 99, chans[3]));
5513 std.posix.close(chans[3]);
5514
5515 try std.testing.expectEqual(@as(?usize, 1), wall_pump.findLocal(&locals, 11));
5516 try std.testing.expectEqual(@as(?usize, null), wall_pump.findLocal(&locals, 99));
5517
5518 // A pipe for the link, which is what a `.fd` transport writes to. The
5519 // frame that comes back out of it is the daemon's only notice that this
5520 // end hung up on the channel.
5521 const link = try std.posix.pipe2(.{ .NONBLOCK = true });
5522 defer std.posix.close(link[0]);
5523 defer std.posix.close(link[1]);
5524 var transport: client.Transport = .{ .conn = .{ .r = link[0], .w = link[1] }, .link = .fd };
5525
5526 wall_pump.closeLocal(&locals, 1, &transport);
5527 try std.testing.expectEqual(@as(?AgentLocal, null), locals[1]);
5528 try std.testing.expect(peerClosed(peers[1]));
5529
5530 const frame = (try proto.readFrame(std.testing.allocator, link[0])) orelse
5531 return error.NoAgentCloseOnLocalClose;
5532 defer frame.deinit(std.testing.allocator);
5533 try std.testing.expectEqual(proto.MsgType.agent_close, frame.type);
5534 try std.testing.expectEqual(@as(u32, 11), try proto.decodeAgentId(frame.payload));
5535
5536 // The redial's sweep: every channel still open goes, and nothing is said
5537 // — the connection that owned them is the one that just died.
5538 wall_pump.dropLocals(&locals);
5539 for (locals) |c| try std.testing.expectEqual(@as(?AgentLocal, null), c);
5540 try std.testing.expect(peerClosed(peers[0]));
5541 try std.testing.expect(peerClosed(peers[2]));
5542 }
5543
5544 /// Whether the other end of a non-blocking pipe has been closed: EOF says
5545 /// yes, EAGAIN says the writer is still holding it.
5546 fn peerClosed(fd: std.posix.fd_t) bool {
5547 var b: [1]u8 = undefined;
5548 const n = std.posix.read(fd, &b) catch return false;
5549 return n == 0;
5550 }
5551
5552 /// Everything waiting on a non-blocking pipe. Non-blocking so a half that
5553 /// stopped writing fails the assertion rather than parking the test on a
5554 /// read that will never return.
5555 fn readAvail(fd: std.posix.fd_t, buf: []u8) []const u8 {
5556 var n: usize = 0;
5557 while (std.posix.read(fd, buf[n..])) |got| {
5558 if (got == 0) break;
5559 n += got;
5560 } else |_| {}
5561 return buf[0..n];
5562 }
5563
5564 test "agent channels: an oversize frame hangs the channel up, a full one lands" {
5565 var locals: [2]?AgentLocal = @splat(null);
5566 const chan = try std.posix.pipe2(.{ .NONBLOCK = true });
5567 defer std.posix.close(chan[0]);
5568 _ = wall_pump.storeLocal(&locals, 5, chan[1]);
5569
5570 const link = try std.posix.pipe2(.{ .NONBLOCK = true });
5571 defer std.posix.close(link[0]);
5572 defer std.posix.close(link[1]);
5573 var transport: client.Transport = .{ .conn = .{ .r = link[0], .w = link[1] }, .link = .fd };
5574
5575 // The ordinary case first, so the refusal below is a refusal and not a
5576 // delivery path that never worked.
5577 var small: [proto.agent_id_len + 2]u8 = undefined;
5578 small[0..proto.agent_id_len].* = proto.encodeAgentId(5);
5579 @memcpy(small[proto.agent_id_len..], "hi");
5580 try std.testing.expect(wall_pump.deliverAgentData(&locals, &small, &transport));
5581 var got: [8]u8 = undefined;
5582 try std.testing.expectEqual(@as(usize, 2), try std.posix.read(chan[0], &got));
5583 try std.testing.expectEqualSlices(u8, "hi", got[0..2]);
5584
5585 // One byte past the cap. The daemon never sends this; a peer that is not
5586 // the daemon we shipped can, and `writeAllFd` would block on it.
5587 const over = try std.testing.allocator.alloc(u8, proto.agent_id_len + proto.agent_data_max + 1);
5588 defer std.testing.allocator.free(over);
5589 @memset(over, 'x');
5590 over[0..proto.agent_id_len].* = proto.encodeAgentId(5);
5591 try std.testing.expect(wall_pump.deliverAgentData(&locals, over, &transport));
5592 try std.testing.expectEqual(@as(?AgentLocal, null), locals[0]);
5593 try std.testing.expect(peerClosed(chan[0]));
5594
5595 // And the daemon is told, because it is holding the far socket open for
5596 // an answer this end will never write.
5597 // A payload too short to name a channel: false, and the pump abandons the
5598 // batch on it rather than skipping one frame, because a stream that has
5599 // lost frame alignment is not one to keep reading.
5600 try std.testing.expect(!wall_pump.deliverAgentData(&locals, "ab", &transport));
5601
5602 const frame = (try proto.readFrame(std.testing.allocator, link[0])) orelse
5603 return error.NoAgentCloseOnOversize;
5604 defer frame.deinit(std.testing.allocator);
5605 try std.testing.expectEqual(proto.MsgType.agent_close, frame.type);
5606 try std.testing.expectEqual(@as(u32, 5), try proto.decodeAgentId(frame.payload));
5607 }
5608
5609 test "agent forwarding is per tile: no -A offers nothing and opens nothing" {
5610 // A REAL agent socket on this machine, listening. Without one, a tile
5611 // that never asked for forwarding and a tile that did both refuse — for
5612 // different reasons — and the consent gate could be deleted under a
5613 // green suite.
5614 var tmp = try TmpDir.make();
5615 defer tmp.cleanup();
5616 var buf: [128]u8 = undefined;
5617 const sock = try std.fmt.bufPrintZ(&buf, "{s}/agent.sock", .{tmp.path()});
5618 const addr = try std.net.Address.initUnix(sock);
5619 var listener = try addr.listen(.{});
5620 defer listener.deinit();
5621
5622 var locals: [2]?AgentLocal = @splat(null);
5623 defer wall_pump.dropLocals(&locals);
5624
5625 // The gate, both ways round, against the same reachable agent.
5626 try std.testing.expect(!wall_pump.openAgentChan(&locals, 1, false, sock));
5627 try std.testing.expectEqual(@as(?usize, null), wall_pump.findLocal(&locals, 1));
5628 try std.testing.expect(wall_pump.openAgentChan(&locals, 1, true, sock));
5629 try std.testing.expect(wall_pump.findLocal(&locals, 1) != null);
5630
5631 // And the daemon does not get told about an agent the tile never
5632 // offered — the offer is what the daemon routes on, so a stray one
5633 // makes this client the answerer for a session it never armed.
5634 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
5635 var t = Tile{
5636 .r = .{ .target = .{ .sock = "/tmp/x" }, .label = "x", .session = "", .agent = false },
5637 .rect = .{ .top = 0, .left = 0, .rows = 4, .cols = 80 },
5638 .shared = &shared,
5639 .idx = 0,
5640 .wake_r = -1,
5641 .wake_w = -1,
5642 };
5643 const link = try std.posix.pipe2(.{ .NONBLOCK = true });
5644 defer std.posix.close(link[0]);
5645 defer std.posix.close(link[1]);
5646 var transport: client.Transport = .{ .conn = .{ .r = link[0], .w = link[1] }, .link = .fd };
5647
5648 try wall_pump.sendAttach(&t, &transport, 0, 0);
5649 const first = (try proto.readFrame(std.testing.allocator, link[0])) orelse
5650 return error.NoAttach;
5651 defer first.deinit(std.testing.allocator);
5652 try std.testing.expectEqual(proto.MsgType.attach, first.type);
5653 // Nothing else on the pipe at all: an empty non-blocking read is the
5654 // only proof that no offer followed, since a frame reader would just
5655 // block waiting for one.
5656 var spare: [1]u8 = undefined;
5657 try std.testing.expectError(error.WouldBlock, std.posix.read(link[0], &spare));
5658
5659 // The positive control on the same pipe: with `-A` the offer follows the
5660 // attach, so the silence above is the gate and not an unwritten frame.
5661 t.r.agent = true;
5662 try wall_pump.sendAttach(&t, &transport, 0, 0);
5663 const attach = (try proto.readFrame(std.testing.allocator, link[0])).?;
5664 defer attach.deinit(std.testing.allocator);
5665 const offer = (try proto.readFrame(std.testing.allocator, link[0])) orelse
5666 return error.NoAgentOffer;
5667 defer offer.deinit(std.testing.allocator);
5668 try std.testing.expectEqual(proto.MsgType.agent_offer, offer.type);
5669 }
5670
5671 test "a view tile's attach makes no size claim; a tile the user asked for does" {
5672 // The daemon reads attach-or-create off the attach's size claim: 0x0
5673 // joins, a real size creates. So a view tile (creates = false) must
5674 // put 0x0 on the wire and leave its rect to the resize doorbell, while
5675 // a tile the user asked for (creates = true) claims its rect in the
5676 // attach itself.
5677 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
5678 // A one-tile wall: no label bar, so viewRows is the whole stripe.
5679 shared.label_rows = 0;
5680 var t = Tile{
5681 .r = .{ .target = .{ .sock = "/tmp/x" }, .label = "x", .session = "", .agent = false },
5682 .rect = .{ .top = 0, .left = 0, .rows = 24, .cols = 80 },
5683 .shared = &shared,
5684 .idx = 0,
5685 .wake_r = -1,
5686 .wake_w = -1,
5687 };
5688 const link = try std.posix.pipe2(.{ .NONBLOCK = true });
5689 defer std.posix.close(link[0]);
5690 defer std.posix.close(link[1]);
5691 var transport: client.Transport = .{ .conn = .{ .r = link[0], .w = link[1] }, .link = .fd };
5692
5693 // A view tile: 0x0 on the wire, and the rect owed to the doorbell.
5694 t.creates = false;
5695 t.resize_pending = false;
5696 try wall_pump.sendAttach(&t, &transport, 0, 0);
5697 const view = (try proto.readFrame(std.testing.allocator, link[0])) orelse
5698 return error.NoAttach;
5699 defer view.deinit(std.testing.allocator);
5700 try std.testing.expectEqual(proto.MsgType.attach, view.type);
5701 const view_req = try proto.decodeAttach(view.payload);
5702 try std.testing.expectEqual(@as(u16, 0), view_req.cols);
5703 try std.testing.expectEqual(@as(u16, 0), view_req.rows);
5704 try std.testing.expect(t.resize_pending);
5705
5706 // The same tile, now one the user asked for: the rect is in the attach,
5707 // and no doorbell is owed.
5708 t.creates = true;
5709 t.resize_pending = false;
5710 try wall_pump.sendAttach(&t, &transport, 0, 0);
5711 const entry = (try proto.readFrame(std.testing.allocator, link[0])) orelse
5712 return error.NoAttach;
5713 defer entry.deinit(std.testing.allocator);
5714 try std.testing.expectEqual(proto.MsgType.attach, entry.type);
5715 const entry_req = try proto.decodeAttach(entry.payload);
5716 try std.testing.expectEqual(@as(u16, 80), entry_req.cols);
5717 try std.testing.expectEqual(@as(u16, 24), entry_req.rows);
5718 try std.testing.expect(!t.resize_pending);
5719 }
5720
5721 test "a newborn tile's first claim is its real stripe, not the placeholder" {
5722 // A chord-born tile (creates = true) puts its rect on the attach frame,
5723 // and the daemon refuses creates under min_session_rows. A 2-row
5724 // stripe under a label bar is 1 content row — below the floor — so a
5725 // placeholder stripe silently freezes the session on a stale grid.
5726 // The tile's stripe must be what relayout would give it BEFORE the
5727 // pump's first sendAttach.
5728 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
5729 shared.label_rows = 1; // two tiles draw a bar
5730 // Real stripe for the second tile of a 2-tile wall on 24 rows.
5731 var t = Tile{
5732 .r = .{ .target = .{ .sock = "/tmp/x" }, .label = "x", .session = "", .agent = false },
5733 .rect = .{ .top = 12, .left = 0, .rows = 12, .cols = 80 },
5734 .shared = &shared,
5735 .idx = 1,
5736 .wake_r = -1,
5737 .wake_w = -1,
5738 };
5739 t.creates = true;
5740 const link = try std.posix.pipe2(.{ .NONBLOCK = true });
5741 defer std.posix.close(link[0]);
5742 defer std.posix.close(link[1]);
5743 var transport: client.Transport = .{ .conn = .{ .r = link[0], .w = link[1] }, .link = .fd };
5744 try wall_pump.sendAttach(&t, &transport, 0, 0);
5745 const fr = (try proto.readFrame(std.testing.allocator, link[0])) orelse
5746 return error.NoAttach;
5747 defer fr.deinit(std.testing.allocator);
5748 const req = try proto.decodeAttach(fr.payload);
5749 // The real stripe's viewRows is 11 (12 - 1 bar), never the 1 a
5750 // placeholder rows=2 stripe would put on the wire.
5751 try std.testing.expectEqual(@as(u16, 80), req.cols);
5752 try std.testing.expectEqual(@as(u16, 11), req.rows);
5753 try std.testing.expect(req.rows >= proto.min_session_rows);
5754 }
5755
5756 test "the cursor sleeps in the focused tile, whoever painted last" {
5757 // Every painter ends by showing the cursor where its own stripe sits, so
5758 // without an owner the visible cursor lands on whichever pump painted
5759 // last. The fix: a focused paint records its screen cursor in Shared, and
5760 // an unfocused paint's last act is to put the cursor back there. A
5761 // focused tile with no core (the unit-test fixture) records nothing and
5762 // restores nothing — the core is what knows the cursor.
5763 const p = try std.posix.pipe2(.{ .NONBLOCK = true });
5764 defer std.posix.close(p[0]);
5765 defer std.posix.close(p[1]);
5766 var shared = Shared{ .out_fd = p[1], .size = .{ .cols = 80, .rows = 24 }, .is_tty = true };
5767 var tiles: [2]Tile = undefined;
5768 tiles[0] = Tile{
5769 .r = .{ .target = .{ .sock = "/s" }, .label = "a", .session = "a" },
5770 .rect = .{ .top = 0, .left = 0, .rows = 12, .cols = 80 },
5771 .shared = &shared,
5772 .idx = 0,
5773 .wake_r = -1,
5774 .wake_w = -1,
5775 };
5776 tiles[1] = Tile{
5777 .r = .{ .target = .{ .sock = "/s" }, .label = "b", .session = "b" },
5778 .rect = .{ .top = 12, .left = 0, .rows = 12, .cols = 80 },
5779 .shared = &shared,
5780 .idx = 1,
5781 .wake_r = -1,
5782 .wake_w = -1,
5783 };
5784 shared.sel = 0;
5785 shared.cursor = .{ .x = 3, .y = 5 };
5786 var buf: [512]u8 = undefined;
5787
5788 // An UNFOCUSED paint (tile 1, core null) ends by restoring the shared
5789 // cursor: hide, CUP to the focused tile's stored position, then show
5790 // again so the cursor rests visible in the focused tile until its own
5791 // next paint — without the trailing show the cursor stays hidden.
5792 _ = wall_pump.tilePaintBegin(&tiles[1]);
5793 wall_pump.tilePaintEnd(&tiles[1]);
5794 try std.testing.expectEqualStrings("\x1b[?25l\x1b[6;4H\x1b[?25h", readAvail(p[0], &buf));
5795
5796 // A FOCUSED paint (tile 0, core null) records nothing and restores
5797 // nothing: no core means the shared cursor is left untouched, and the
5798 // pipe receives no new bytes.
5799 _ = wall_pump.tilePaintBegin(&tiles[0]);
5800 wall_pump.tilePaintEnd(&tiles[0]);
5801 try std.testing.expectEqual(@as(usize, 0), readAvail(p[0], &buf).len);
5802 }
5803
5804 test "saveLayoutTo writes the sidecar for the present tiles, spellings verbatim" {
5805 // A wall saves its tree on the way out: the leaf spellings are the
5806 // tiles' labels verbatim, so a reload restores the same view.
5807 const alloc = std.testing.allocator;
5808 var tmp = try TmpDir.make();
5809 defer tmp.cleanup();
5810 const path = try std.fmt.allocPrint(alloc, "{s}/layout", .{tmp.path()});
5811 defer alloc.free(path);
5812 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
5813 shared.tree = layout.Tree.init(alloc);
5814 shared.flat_alloc = alloc;
5815 defer shared.tree.deinit();
5816 defer if (shared.last_flat) |*f| f.deinit(alloc);
5817 defer if (shared.base_flat) |*f| f.deinit(alloc);
5818 try shared.tree.addFirst(0);
5819 try shared.tree.splitRight(0, 1);
5820 const tiles = try alloc.alloc(Tile, 2);
5821 defer alloc.free(tiles);
5822 for (tiles, 0..) |*t, i| {
5823 t.* = Tile{
5824 .r = .{
5825 .target = .{ .sock = "/tmp/x" },
5826 .label = switch (i) {
5827 0 => "--sock /tmp/x#a",
5828 else => "--sock /tmp/x#b",
5829 },
5830 .session = "",
5831 },
5832 .rect = .{ .top = 0, .left = 0, .rows = 24, .cols = 80 },
5833 .shared = &shared,
5834 .idx = i,
5835 .wake_r = -1,
5836 .wake_w = -1,
5837 };
5838 }
5839 const present = [_]bool{ true, true };
5840 wall_layout.saveLayoutTo(alloc, path, tiles, &present, &shared);
5841 const got = wall.loadLayout(alloc, path) orelse return error.TestUnexpectedResult;
5842 defer alloc.free(got);
5843 try std.testing.expect(std.mem.startsWith(u8, got, "mux-layout 1\nbeside 0\n"));
5844 try std.testing.expect(std.mem.indexOf(u8, got, "--sock /tmp/x#b\n") != null);
5845 }
5846
5847 test "saveLayoutTo handles a vanished middle tile without panicking" {
5848 // Tiles are never compacted (pump threads hold pointers into the
5849 // array), so a vanished middle tile leaves a hole: present = {t,f,t}
5850 // and the tree holds leaves 0 and 2. serialize indexes spellings by
5851 // leaf ID, so the array must be tile-indexed — a dense array would
5852 // panic on spellings[2] with only two entries.
5853 const alloc = std.testing.allocator;
5854 var tmp = try TmpDir.make();
5855 defer tmp.cleanup();
5856 const path = try std.fmt.allocPrint(alloc, "{s}/layout", .{tmp.path()});
5857 defer alloc.free(path);
5858 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
5859 shared.tree = layout.Tree.init(alloc);
5860 shared.flat_alloc = alloc;
5861 defer shared.tree.deinit();
5862 defer if (shared.last_flat) |*f| f.deinit(alloc);
5863 defer if (shared.base_flat) |*f| f.deinit(alloc);
5864 try shared.tree.addFirst(0);
5865 try shared.tree.splitRight(0, 1);
5866 try shared.tree.splitRight(1, 2);
5867 // Tile 1 vanished: remove leaf 1 from the tree.
5868 shared.tree.remove(1);
5869 const tiles = try alloc.alloc(Tile, 3);
5870 defer alloc.free(tiles);
5871 for (tiles, 0..) |*t, i| {
5872 t.* = Tile{
5873 .r = .{
5874 .target = .{ .sock = "/tmp/x" },
5875 .label = switch (i) {
5876 0 => "--sock /tmp/x#a",
5877 1 => "--sock /tmp/x#b",
5878 else => "--sock /tmp/x#c",
5879 },
5880 .session = "",
5881 },
5882 .rect = .{ .top = 0, .left = 0, .rows = 24, .cols = 80 },
5883 .shared = &shared,
5884 .idx = i,
5885 .wake_r = -1,
5886 .wake_w = -1,
5887 };
5888 }
5889 const present = [_]bool{ true, false, true };
5890 wall_layout.saveLayoutTo(alloc, path, tiles, &present, &shared);
5891 const got = wall.loadLayout(alloc, path) orelse return error.TestUnexpectedResult;
5892 defer alloc.free(got);
5893 // Tile 2's label is verbatim; the hole (tile 1) is never serialized.
5894 try std.testing.expect(std.mem.indexOf(u8, got, "--sock /tmp/x#c\n") != null);
5895 try std.testing.expect(std.mem.indexOf(u8, got, "--sock /tmp/x#b") == null);
5896 }
5897
5898 test "restore: a saved beside pair comes back verbatim on a tall terminal" {
5899 // Aspect at 40x30 would say stacked; the sidecar says beside and wins.
5900 const alloc = std.testing.allocator;
5901 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 40, .rows = 30 }, .is_tty = false };
5902 shared.tree = layout.Tree.init(alloc);
5903 shared.flat_alloc = alloc;
5904 defer shared.tree.deinit();
5905 defer if (shared.last_flat) |*f| f.deinit(alloc);
5906 defer if (shared.base_flat) |*f| f.deinit(alloc);
5907 const resolved = [_]Resolved{
5908 .{ .target = .{ .sock = "/tmp/x" }, .label = "--sock /tmp/x#a", .session = "a" },
5909 .{ .target = .{ .sock = "/tmp/x" }, .label = "--sock /tmp/x#b", .session = "b" },
5910 };
5911 const bytes = "mux-layout 1\nbeside 0\n leaf 40 --sock /tmp/x#a\n leaf 39 --sock /tmp/x#b\n";
5912 var focus_out: ?u8 = null;
5913 try std.testing.expect(wall_layout.restoreLayout(alloc, &resolved, &shared, bytes, &focus_out));
5914 const tiles = try alloc.alloc(Tile, 2);
5915 defer alloc.free(tiles);
5916 const present = [_]bool{ true, true };
5917 for (tiles, 0..) |*t, i| {
5918 t.* = Tile{
5919 .r = resolved[i],
5920 .rect = .{ .top = 0, .left = 0, .rows = 30, .cols = 40 },
5921 .shared = &shared,
5922 .idx = i,
5923 .wake_r = -1,
5924 .wake_w = -1,
5925 };
5926 }
5927 wall_layout.relayout(alloc, tiles, &present, &shared, 0);
5928 try std.testing.expectEqual(@as(u16, 0), tiles[1].rect.top);
5929 try std.testing.expect(tiles[1].rect.left > 0);
5930 }
5931
5932 test "restore: heals — unknown wall line inserted, lost leaf dropped" {
5933 // Sidecar knows a, GONE and c side by side; the wall has a, c and NEW.
5934 // Three saved leaves, not two: with two, dropping one collapses the
5935 // container and every heal lands on the default cut, so the claim
5936 // ("inserted BESIDE the match") could not be seen.
5937 const alloc = std.testing.allocator;
5938 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
5939 shared.tree = layout.Tree.init(alloc);
5940 shared.flat_alloc = alloc;
5941 defer shared.tree.deinit();
5942 defer if (shared.last_flat) |*f| f.deinit(alloc);
5943 defer if (shared.base_flat) |*f| f.deinit(alloc);
5944 const resolved = [_]Resolved{
5945 .{ .target = .{ .sock = "/tmp/x" }, .label = "--sock /tmp/x#a", .session = "a" },
5946 .{ .target = .{ .sock = "/tmp/x" }, .label = "--sock /tmp/x#c", .session = "c" },
5947 .{ .target = .{ .sock = "/tmp/x" }, .label = "--sock /tmp/x#new", .session = "new" },
5948 };
5949 const bytes = "mux-layout 1\nbeside 0\n leaf 26 --sock /tmp/x#a\n" ++
5950 " leaf 26 --sock /tmp/x#gone\n leaf 26 --sock /tmp/x#c\n";
5951 var focus_out: ?u8 = null;
5952 try std.testing.expect(wall_layout.restoreLayout(alloc, &resolved, &shared, bytes, &focus_out));
5953 try std.testing.expectEqual(@as(usize, 3), shared.tree.count());
5954 const flat = try shared.tree.flatten(alloc, 24, 80, wall_layout.wallFloors(3), null);
5955 defer flat.deinit(alloc);
5956 const a = flat.rectOf(0) orelse return error.TestUnexpectedResult;
5957 const c = flat.rectOf(1) orelse return error.TestUnexpectedResult;
5958 const new = flat.rectOf(2) orelse return error.TestUnexpectedResult;
5959 // The saved axis survives the drop — a heal that rebuilt the default
5960 // cut would stack these, giving them one left and three tops.
5961 try std.testing.expectEqual(a.top, c.top);
5962 try std.testing.expectEqual(a.top, new.top);
5963 // NEW landed next to the match (a), not at either end by accident.
5964 try std.testing.expect(a.left < new.left);
5965 try std.testing.expect(new.left < c.left);
5966 }
5967
5968 test "restore: zero matches or garbage degrade to false" {
5969 const alloc = std.testing.allocator;
5970 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
5971 shared.tree = layout.Tree.init(alloc);
5972 shared.flat_alloc = alloc;
5973 defer shared.tree.deinit();
5974 defer if (shared.last_flat) |*f| f.deinit(alloc);
5975 defer if (shared.base_flat) |*f| f.deinit(alloc);
5976 const resolved = [_]Resolved{
5977 .{ .target = .{ .sock = "/tmp/x" }, .label = "--sock /tmp/x#a", .session = "a" },
5978 };
5979 var focus_out: ?u8 = null;
5980 try std.testing.expect(!wall_layout.restoreLayout(alloc, &resolved, &shared, "not a sidecar", &focus_out));
5981 const nomatch = "mux-layout 1\nleaf 0 --sock /nowhere#z\n";
5982 try std.testing.expect(!wall_layout.restoreLayout(alloc, &resolved, &shared, nomatch, &focus_out));
5983 }
5984
5985 test "restore: duplicate spellings pair positionally" {
5986 // Wall shows one host twice: same spelling on both lines. Saved
5987 // leaves 0 and 1 share it; leaf 0 takes wall 0, leaf 1 takes wall 1.
5988 const alloc = std.testing.allocator;
5989 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
5990 shared.tree = layout.Tree.init(alloc);
5991 shared.flat_alloc = alloc;
5992 defer shared.tree.deinit();
5993 defer if (shared.last_flat) |*f| f.deinit(alloc);
5994 defer if (shared.base_flat) |*f| f.deinit(alloc);
5995 const resolved = [_]Resolved{
5996 .{ .target = .{ .sock = "/tmp/x" }, .label = "--sock /tmp/x#dup", .session = "dup" },
5997 .{ .target = .{ .sock = "/tmp/x" }, .label = "--sock /tmp/x#dup", .session = "dup" },
5998 };
5999 // Lopsided on purpose: with near-equal weights a mispairing produces
6000 // near-equal rects and hides in the leaf count.
6001 const bytes = "mux-layout 1\nbeside 0\n leaf 59 --sock /tmp/x#dup\n leaf 20 --sock /tmp/x#dup\n";
6002 var focus_out: ?u8 = null;
6003 try std.testing.expect(wall_layout.restoreLayout(alloc, &resolved, &shared, bytes, &focus_out));
6004 try std.testing.expectEqual(@as(usize, 2), shared.tree.count());
6005 // Pairing is the whole claim and it IS observable: saved leaf 0 owns
6006 // the left, wide slot, so it must be wall tile 0. Paired the other
6007 // way round the count is still 2 and the tiles have swapped screens.
6008 const flat = try shared.tree.flatten(alloc, 24, 80, wall_layout.wallFloors(2), null);
6009 defer flat.deinit(alloc);
6010 const t0 = flat.rectOf(0) orelse return error.TestUnexpectedResult;
6011 const t1 = flat.rectOf(1) orelse return error.TestUnexpectedResult;
6012 try std.testing.expect(t0.left < t1.left);
6013 try std.testing.expect(t0.cols > t1.cols);
6014 }
6015
6016 test "restoreLayoutFrom: dense sidecar indices land on the real tile indices" {
6017 // Tile 1 was forgotten, so the dense array is 0,2,3 and the two index
6018 // spaces disagree. With no hole they coincide and the second remap is
6019 // untestable by construction — which is how a tile could be handed the
6020 // rect flattened for its neighbour and no test would say so.
6021 const alloc = std.testing.allocator;
6022 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
6023 shared.tree = layout.Tree.init(alloc);
6024 shared.flat_alloc = alloc;
6025 defer shared.tree.deinit();
6026 defer if (shared.last_flat) |*f| f.deinit(alloc);
6027 defer if (shared.base_flat) |*f| f.deinit(alloc);
6028 const labels = [_][]const u8{
6029 "--sock /tmp/x#a",
6030 "--sock /tmp/x#hole",
6031 "--sock /tmp/x#c",
6032 "--sock /tmp/x#d",
6033 };
6034 var tiles: [4]Tile = undefined;
6035 for (&tiles, 0..) |*t, i| {
6036 t.* = .{
6037 .r = .{ .target = .{ .sock = "/tmp/x" }, .label = labels[i], .session = "" },
6038 .rect = .{ .top = 0, .left = 0, .rows = 24, .cols = 80 },
6039 .shared = &shared,
6040 .idx = i,
6041 .wake_r = -1,
6042 .wake_w = -1,
6043 };
6044 }
6045 var present = [_]bool{ true, false, true, true };
6046 // Three unequal weights over 80 columns less two rails: 40+20+18 = 78,
6047 // so each tile's width names which saved leaf it was given.
6048 const bytes = "mux-layout 1\nbeside 0\n leaf 40 --sock /tmp/x#a\n" ++
6049 " leaf 20 --sock /tmp/x#c\n leaf 18 --sock /tmp/x#d\n";
6050 var restored_focus: ?usize = null;
6051 wall_layout.restoreLayoutFrom(alloc, &tiles, &present, 4, &shared, bytes, &restored_focus) orelse
6052 return error.TestUnexpectedResult;
6053 const flat = try shared.tree.flatten(alloc, 24, 80, wall_layout.wallFloors(3), null);
6054 defer flat.deinit(alloc);
6055 try std.testing.expectEqual(@as(?layout.Rect, null), flat.rectOf(1));
6056 const a = flat.rectOf(0) orelse return error.TestUnexpectedResult;
6057 const c = flat.rectOf(2) orelse return error.TestUnexpectedResult;
6058 const d = flat.rectOf(3) orelse return error.TestUnexpectedResult;
6059 try std.testing.expectEqual(@as(u16, 40), a.cols);
6060 try std.testing.expectEqual(@as(u16, 20), c.cols);
6061 try std.testing.expectEqual(@as(u16, 18), d.cols);
6062 try std.testing.expect(a.left < c.left);
6063 try std.testing.expect(c.left < d.left);
6064 }
6065
6066 test "restore: focus_out maps the saved focus through the spelling match" {
6067 // Saved leaf 1 matches wall tile 1 (b) → focus_out == 1. Saved leaf 1
6068 // matches NO wall tile (gone) → focus_out stays null.
6069 const alloc = std.testing.allocator;
6070
6071 // Matched: focus 1 → tile 1.
6072 {
6073 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
6074 shared.tree = layout.Tree.init(alloc);
6075 shared.flat_alloc = alloc;
6076 defer shared.tree.deinit();
6077 defer if (shared.last_flat) |*f| f.deinit(alloc);
6078 defer if (shared.base_flat) |*f| f.deinit(alloc);
6079 const resolved = [_]Resolved{
6080 .{ .target = .{ .sock = "/tmp/x" }, .label = "--sock /tmp/x#a", .session = "a" },
6081 .{ .target = .{ .sock = "/tmp/x" }, .label = "--sock /tmp/x#b", .session = "b" },
6082 };
6083 const bytes = "mux-layout 1\nbeside 0\n leaf 40 --sock /tmp/x#a\n leaf 39 --sock /tmp/x#b\nfocus 1\n";
6084 var focus_out: ?u8 = null;
6085 try std.testing.expect(wall_layout.restoreLayout(alloc, &resolved, &shared, bytes, &focus_out));
6086 try std.testing.expectEqual(@as(?u8, 1), focus_out);
6087 }
6088
6089 // Unmatched: saved leaf 1 (gone) has no wall tile → focus_out null.
6090 {
6091 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
6092 shared.tree = layout.Tree.init(alloc);
6093 shared.flat_alloc = alloc;
6094 defer shared.tree.deinit();
6095 defer if (shared.last_flat) |*f| f.deinit(alloc);
6096 defer if (shared.base_flat) |*f| f.deinit(alloc);
6097 const resolved = [_]Resolved{
6098 .{ .target = .{ .sock = "/tmp/x" }, .label = "--sock /tmp/x#a", .session = "a" },
6099 .{ .target = .{ .sock = "/tmp/x" }, .label = "--sock /tmp/x#new", .session = "new" },
6100 };
6101 const bytes = "mux-layout 1\nbeside 0\n leaf 40 --sock /tmp/x#a\n leaf 39 --sock /tmp/x#gone\nfocus 1\n";
6102 var focus_out: ?u8 = null;
6103 try std.testing.expect(wall_layout.restoreLayout(alloc, &resolved, &shared, bytes, &focus_out));
6104 try std.testing.expectEqual(@as(?u8, null), focus_out);
6105 }
6106 }
6107
6108 test "tileBanner: a prompt wider than its pane shows the tail inside the pane" {
6109 // A tile that painted a whole long spelling would run across the rail
6110 // into its neighbour, so the banner is bounded by the tile's rect and
6111 // not by the terminal. The tail is what is kept: the cursor end of the
6112 // line the user is still typing.
6113 const pipe = try std.posix.pipe();
6114 defer std.posix.close(pipe[0]);
6115 var shared = Shared{ .out_fd = pipe[1], .size = .{ .cols = 80, .rows = 24 }, .is_tty = true };
6116 shared.label_rows = 1;
6117 var t = Tile{
6118 .r = .{ .target = .{ .sock = "/tmp/x" }, .label = "x", .session = "" },
6119 .rect = .{ .top = 2, .left = 5, .rows = 5, .cols = 10 },
6120 .shared = &shared,
6121 .idx = 0,
6122 .wake_r = -1,
6123 .wake_w = -1,
6124 };
6125 const text = ": --sock /tmp/very/long/paths_";
6126 try std.testing.expectEqual(@as(usize, 30), text.len);
6127 tileBanner(&t, text);
6128 std.posix.close(pipe[1]);
6129
6130 var out: [256]u8 = undefined;
6131 const n = try std.posix.read(pipe[0], &out);
6132 const got = out[0..n];
6133 // The tile's own corner: rect.top + label_rows + 1, rect.left + 1.
6134 try std.testing.expect(std.mem.indexOf(u8, got, "\x1b[4;6H") != null);
6135 const from = std.mem.indexOf(u8, got, "\x1b[7m").? + 4;
6136 const to = std.mem.indexOf(u8, got, "\x1b[0m").?;
6137 try std.testing.expectEqualStrings(text[text.len - 10 ..], got[from..to]);
6138 }
6139
6140 test "otherHosts: the dialled host is not tiled twice, and the rest follow in file order" {
6141 const alloc = std.testing.allocator;
6142 var tmp = try TmpDir.make();
6143 defer tmp.cleanup();
6144 var buf: [256]u8 = undefined;
6145 const path = try std.fmt.bufPrint(&buf, "{s}/hosts", .{tmp.path()});
6146 // Off-origin: the host the user dialled is the file's SECOND line, so a
6147 // skip that only ever worked on line 0 is caught here.
6148 try wall.saveBytes(path, "--sock /a\n--sock /b\n");
6149
6150 var arena_state = std.heap.ArenaAllocator.init(alloc);
6151 defer arena_state.deinit();
6152 const arena = arena_state.allocator();
6153 var specs: std.ArrayList(HostSpec) = .empty;
6154 try specs.append(arena, .{ .spelling = "--sock /b", .target = .{ .sock = "/b" }, .poll_target = .{ .sock = "/b" } });
6155 wall_host.otherHosts(arena, &specs, "--sock /b", path, null, 0);
6156
6157 try std.testing.expectEqual(@as(usize, 2), specs.items.len);
6158 try std.testing.expectEqualStrings("--sock /b", specs.items[0].spelling);
6159 try std.testing.expectEqualStrings("--sock /a", specs.items[1].spelling);
6160
6161 // A file the grammar refuses costs the user the WALL, never the session
6162 // they asked for: the entry host stands alone and the attach goes on.
6163 try wall.saveBytes(path, "--sock /a\nbox#old\n");
6164 var one: std.ArrayList(HostSpec) = .empty;
6165 try one.append(arena, .{ .spelling = "--sock /b", .target = .{ .sock = "/b" }, .poll_target = .{ .sock = "/b" } });
6166 wall_host.otherHosts(arena, &one, "--sock /b", path, null, 0);
6167 try std.testing.expectEqual(@as(usize, 1), one.items.len);
6168 } 2250 }