a73x

src/tui/wall_test_layout.zig

Ref:   Size: 63.3 KiB   History

//! The pane tree, the rects it cuts and the layout file (wall_layout.zig).
const std = @import("std");
const proto = @import("term").protocol;
const hosts = @import("client").hosts;
const layout = @import("client").layout;
const TmpDir = @import("testtmp").TmpDir;
const fixture = @import("wall_test_harness.zig");
const wall_host = @import("wall_host.zig");
const wall_layout = @import("wall_layout.zig");
const wv = @import("wallview.zig");
const Host = wall_host.Host;
const ResizeWitness = fixture.ResizeWitness;
const Shared = wv.Shared;
const Tile = wv.Tile;
const WallScreen = fixture.WallScreen;

test "the tree's stacked cut splits rows with the remainder at the top" {
    const alloc = std.testing.allocator;
    var tree = layout.Tree.init(alloc);
    defer tree.deinit();
    try tree.addFirst(0);
    try tree.insert(0, 1);
    try tree.insert(1, 2);
    var f = try tree.flatten(alloc, 25, 80, wall_layout.wallFloors(true), null);
    defer f.deinit(alloc);
    try std.testing.expectEqual(layout.Rect{ .top = 0, .left = 0, .rows = 9, .cols = 80 }, f.rectOf(0).?);
    try std.testing.expectEqual(layout.Rect{ .top = 9, .left = 0, .rows = 8, .cols = 80 }, f.rectOf(1).?);
    try std.testing.expectEqual(layout.Rect{ .top = 17, .left = 0, .rows = 8, .cols = 80 }, f.rectOf(2).?);
}

test "the tree refuses a wall that cannot show a content row" {
    const alloc = std.testing.allocator;
    var tree = layout.Tree.init(alloc);
    defer tree.deinit();
    // 12 tiles over 23 rows: 1 row each — a label with no content.
    try tree.addFirst(0);
    for (1..12) |i| try tree.insert(@intCast(i - 1), @intCast(i));
    try std.testing.expectError(error.TooSmall, tree.flatten(alloc, 23, 80, wall_layout.wallFloors(true), null));
}

test "the tree refuses a multi-tile cut too thin for the daemon's row floor" {
    const alloc = std.testing.allocator;
    var tree = layout.Tree.init(alloc);
    defer tree.deinit();
    try tree.addFirst(0);
    try tree.insert(0, 1);
    // Two tiles on five rows: each rect is two rows, and under a label
    // bar that is one content row — below `min_session_rows`, so the
    // daemon refuses the resize and the tile freezes on a stale grid.
    try std.testing.expectError(error.TooSmall, tree.flatten(alloc, 5, 80, wall_layout.wallFloors(true), null));
    try std.testing.expectError(error.TooSmall, tree.flatten(alloc, 4, 80, wall_layout.wallFloors(true), null));
    // Six rows is the first cut that fits: three a rect, two of them
    // content under the bar — exactly the daemon's row floor.
    var f = try tree.flatten(alloc, 6, 80, wall_layout.wallFloors(true), null);
    defer f.deinit(alloc);
    try std.testing.expectEqual(@as(u16, 3), f.rectOf(0).?.rows);
    try std.testing.expectEqual(@as(u16, 3), f.rectOf(1).?.rows);
    var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 6 }, .is_tty = true };
    var t0 = Tile{
        .r = .{ .target = .{ .sock = "/tmp/x" }, .label = "x", .session = "" },
        .rect = f.rectOf(0).?,
        .shared = &shared,
        .idx = 0,
        .wake_r = -1,
        .wake_w = -1,
    };
    var t1 = Tile{
        .r = .{ .target = .{ .sock = "/tmp/y" }, .label = "y", .session = "" },
        .rect = f.rectOf(1).?,
        .shared = &shared,
        .idx = 1,
        .wake_r = -1,
        .wake_w = -1,
    };
    try std.testing.expectEqual(proto.min_session_rows, t0.viewRows());
    try std.testing.expectEqual(proto.min_session_rows, t1.viewRows());
    // A piped wall draws no bar, so its floor is the daemon's alone.
    var one = layout.Tree.init(alloc);
    defer one.deinit();
    try one.addFirst(0);
    var of = try one.flatten(alloc, 2, 80, wall_layout.wallFloors(false), null);
    defer of.deinit(alloc);
    try std.testing.expectEqual(@as(u16, 2), of.rectOf(0).?.rows);
}

test "viewRows: a bar-less wall keeps every row, a barred tile loses one" {
    // No bar (a pipe): the tile claims every row.
    var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
    var t0 = Tile{
        .r = .{ .target = .{ .sock = "/tmp/x" }, .label = "x", .session = "" },
        .rect = .{ .top = 0, .left = 0, .rows = 24, .cols = 80 },
        .shared = &shared,
        .idx = 0,
        .wake_r = -1,
        .wake_w = -1,
    };
    try std.testing.expectEqual(@as(u16, 24), t0.viewRows());

    // Under a bar (any tty wall): each tile loses one row to it.
    shared.is_tty = true;
    var t1 = Tile{
        .r = .{ .target = .{ .sock = "/tmp/x" }, .label = "x", .session = "" },
        .rect = .{ .top = 0, .left = 0, .rows = 12, .cols = 80 },
        .shared = &shared,
        .idx = 0,
        .wake_r = -1,
        .wake_w = -1,
    };
    var t2 = Tile{
        .r = .{ .target = .{ .sock = "/tmp/y" }, .label = "y", .session = "" },
        .rect = .{ .top = 12, .left = 0, .rows = 12, .cols = 80 },
        .shared = &shared,
        .idx = 1,
        .wake_r = -1,
        .wake_w = -1,
    };
    try std.testing.expectEqual(@as(u16, 11), t1.viewRows());
    try std.testing.expectEqual(@as(u16, 11), t2.viewRows());
}

test "relayout sets resize_pending on every live tile" {
    const alloc = std.testing.allocator;
    var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = true };
    shared.tree = layout.Tree.init(alloc);
    shared.flat_alloc = alloc;
    defer shared.tree.deinit();
    defer if (shared.last_flat) |*f| f.deinit(alloc);
    defer if (shared.base_flat) |*f| f.deinit(alloc);
    try shared.tree.addFirst(0);
    try shared.tree.insert(0, 1);
    const tiles = try alloc.alloc(Tile, 2);
    defer alloc.free(tiles);
    const present = try alloc.alloc(bool, 2);
    defer alloc.free(present);
    present[0] = true;
    present[1] = true;
    for (tiles, 0..) |*t, i| {
        t.* = Tile{
            .r = .{ .target = .{ .sock = "/tmp/x" }, .label = "x", .session = "" },
            .rect = .{ .top = 0, .left = 0, .rows = 24, .cols = 80 },
            .shared = &shared,
            .idx = i,
            .wake_r = -1,
            .wake_w = -1,
        };
    }
    wall_layout.relayout(fixture.wallAll(alloc, tiles, present, &shared), 0);
    // A tty wall draws its label bar.
    try std.testing.expectEqual(@as(u8, 1), shared.labelRows());
    // Every tile is doorbelled: its pump sends the new rect.
    try std.testing.expect(tiles[0].resize_pending);
    try std.testing.expect(tiles[1].resize_pending);
}

test "a relayout landing mid-pass is still sent" {
    const alloc = std.testing.allocator;
    var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
    shared.tree = layout.Tree.init(alloc);
    shared.flat_alloc = alloc;
    defer shared.tree.deinit();
    defer if (shared.last_flat) |*f| f.deinit(alloc);
    defer if (shared.base_flat) |*f| f.deinit(alloc);
    try shared.tree.addFirst(0);
    try shared.tree.insert(0, 1);
    const tiles = try alloc.alloc(Tile, 2);
    defer alloc.free(tiles);
    var present = [_]bool{ true, true };
    for (tiles, 0..) |*t, i| {
        t.* = Tile{
            .r = .{ .target = .{ .sock = "/tmp/x" }, .label = "x", .session = "" },
            .rect = .{ .top = 0, .left = 0, .rows = 24, .cols = 80 },
            .shared = &shared,
            .idx = i,
            .wake_r = -1,
            .wake_w = -1,
        };
    }

    var w = ResizeWitness{ .t = &tiles[0] };
    const th = try std.Thread.spawn(.{}, ResizeWitness.run, .{&w});
    defer {
        w.stop.store(true, .release);
        th.join();
    }
    // The dimension this fixture varies is TIME: the wall is re-cut while a
    // pump is mid-pass, over and over, so a relayout is free to land at any
    // point inside one. Both heights clear `wallFloors(2)`, so every cut
    // really does move the rect.
    const heights = [_]u16{ 24, 22, 24, 23 };
    var i: usize = 0;
    while (i < 400) : (i += 1) {
        shared.paint_mu.lock();
        shared.size = .{ .cols = 80, .rows = heights[i % heights.len] };
        shared.paint_mu.unlock();
        wall_layout.relayout(fixture.wallAll(alloc, tiles, &present, &shared), 0);

        // Quiesce: the pump owes nothing, and the pass that took the last
        // doorbell has finished. Only then is "what the daemon holds" a
        // settled question.
        while (true) {
            shared.paint_mu.lock();
            const owed = tiles[0].resize_pending;
            shared.paint_mu.unlock();
            if (!owed) break;
        }
        const n = w.passes.load(.acquire);
        while (w.passes.load(.acquire) < n + 1) {}

        shared.paint_mu.lock();
        const want = ResizeWitness.pack(tiles[0].rect.cols, tiles[0].rect.rows -| shared.labelRows());
        shared.paint_mu.unlock();
        // The claim: a quiet wall and a quiet pump agree on the grid. A
        // doorbell consumed apart from the rect it describes breaks this —
        // the pump sent the rect it snapshotted BEFORE the re-cut, cleared
        // the flag, and nothing re-sends.
        try std.testing.expectEqual(want, w.sent.load(.acquire));
    }
}

test "a terminal too small for the cut falls back to the focused pane, and grows back" {
    const alloc = std.testing.allocator;
    const n = 7;
    var screen = try WallScreen.init(alloc, 80, 24);
    defer screen.deinit();
    var shared = Shared{ .out_fd = screen.w, .size = .{ .cols = 80, .rows = 24 }, .is_tty = true };
    shared.tree = layout.Tree.init(alloc);
    shared.flat_alloc = alloc;
    defer shared.tree.deinit();
    defer if (shared.last_flat) |*f| f.deinit(alloc);
    defer if (shared.base_flat) |*f| f.deinit(alloc);
    try shared.tree.addFirst(0);
    var k: u8 = 1;
    while (k < n) : (k += 1) try shared.tree.insert(k - 1, k);
    const tiles = try alloc.alloc(Tile, n);
    defer alloc.free(tiles);
    const present = try alloc.alloc(bool, n);
    defer alloc.free(present);
    var labels: [n][2]u8 = undefined;
    for (tiles, 0..) |*t, i| {
        present[i] = true;
        labels[i] = .{ 't', '0' + @as(u8, @intCast(i)) };
        t.* = Tile{
            .r = .{ .target = .{ .sock = "/tmp/x" }, .label = &labels[i], .session = "" },
            .rect = .{ .top = 0, .left = 0, .rows = 24, .cols = 80 },
            .shared = &shared,
            .idx = i,
            .wake_r = -1,
            .wake_w = -1,
        };
    }
    // One tile whose pump has ended: the keyboard is the only thread left
    // that can paint its bar, so it is the tile that proves a HIDDEN pane
    // gets no bar either.
    tiles[n - 1].alive.store(false, .release);

    // The dimension this fixture varies is the terminal's height — the one
    // the wall is cut against. 24 rows hold seven panes under their bars;
    // 20 do not, and `flatten` says so; and the wall has to come back.
    for ([_]u16{ 24, 20, 24 }) |rows| {
        try screen.eng.resize(80, rows);
        shared.paint_mu.lock();
        shared.size = .{ .cols = 80, .rows = rows };
        shared.paint_mu.unlock();
        wall_layout.relayout(fixture.wallAll(alloc, tiles, present, &shared), 0);
        // What the pumps do with the generation bump: repaint their bars.
        for (tiles, present) |*t, p| if (p) wv.paintLabel(t, .up);
        screen.drain();
        const dump = try screen.eng.dumpPlain(alloc);
        defer alloc.free(dump);

        // No tile ever claims a row the terminal does not have. A SIGWINCH
        // is not an operation and cannot be refused: doing nothing leaves
        // every rect pointing past the bottom of the screen.
        for (tiles, present) |*t, p| {
            if (!p) continue;
            try std.testing.expect(t.rect.top + t.rect.rows <= rows);
            try std.testing.expect(t.rect.left + t.rect.cols <= 80);
        }
        if (rows == 20) {
            // The degrade: the focused pane whole, every other pane at 0x0
            // — a rect that paints nothing and claims no size — and the
            // tree untouched underneath. The bar stays: it follows the
            // tty, and the one visible pane still deserves its name.
            try std.testing.expectEqual(@as(u16, 1), shared.labelRows());
            try std.testing.expectEqual(@as(u16, 20), tiles[0].rect.rows);
            try std.testing.expectEqual(@as(u16, 80), tiles[0].rect.cols);
            try std.testing.expectEqual(@as(u16, 0), tiles[0].rect.top);
            for (tiles[1..]) |*t| try std.testing.expectEqual(@as(u16, 0), t.rect.rows);
            // The focused pane's bar is the only ink: a hidden tile's
            // 0-wide rect can paint no bar, so every other row the wall
            // itself wrote is blank.
            const l0 = WallScreen.line(dump, 0) orelse return error.NoSuchRow;
            try std.testing.expect(std.mem.indexOf(u8, l0, tiles[0].r.label) != null);
            var it = std.mem.splitScalar(u8, dump, '\n');
            var row: usize = 0;
            while (it.next()) |l| : (row += 1) {
                if (row == 0) continue;
                try std.testing.expectEqual(@as(usize, 0), std.mem.trim(u8, l, " ").len);
            }
        } else {
            try std.testing.expectEqual(@as(u16, 1), shared.labelRows());
            // Seven bars, each on the first row of the pane it names.
            for (tiles) |*t| {
                const l = WallScreen.line(dump, t.rect.top) orelse return error.NoSuchRow;
                try std.testing.expect(std.mem.indexOf(u8, l, t.r.label) != null);
            }
        }
    }
}

test "fullscreen gives the focused tile the whole terminal and hides the rest" {
    const alloc = std.testing.allocator;
    var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
    shared.tree = layout.Tree.init(alloc);
    shared.flat_alloc = alloc;
    defer shared.tree.deinit();
    defer if (shared.last_flat) |*f| f.deinit(alloc);
    defer if (shared.base_flat) |*f| f.deinit(alloc);
    try shared.tree.addFirst(0);
    try shared.tree.insert(0, 1);
    const tiles = try alloc.alloc(Tile, 2);
    defer alloc.free(tiles);
    var present = [_]bool{ true, true };
    for (tiles, 0..) |*t, i| {
        t.* = Tile{
            .r = .{ .target = .{ .sock = "/tmp/x" }, .label = "x", .session = "" },
            .rect = .{ .top = 0, .left = 0, .rows = 24, .cols = 80 },
            .shared = &shared,
            .idx = i,
            .wake_r = -1,
            .wake_w = -1,
        };
    }
    shared.fullscreen = true;
    wall_layout.relayout(fixture.wallAll(alloc, tiles, &present, &shared), 0);
    // No label bar on a pipe, fullscreened or not — the bar is the tty's.
    try std.testing.expectEqual(@as(u8, 0), shared.labelRows());
    // Tile 0 gets the whole terminal; tile 1 gets nothing.
    try std.testing.expectEqual(@as(u16, 24), tiles[0].rect.rows);
    try std.testing.expectEqual(@as(u16, 80), tiles[0].rect.cols);
    try std.testing.expectEqual(@as(u16, 0), tiles[1].rect.rows);
    try std.testing.expectEqual(@as(u16, 0), tiles[1].rect.cols);
    // The base flat is kept for neighbor.
    try std.testing.expect(shared.base_flat != null);
}

test "toggle off fullscreen restores the real rects" {
    const alloc = std.testing.allocator;
    var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = true };
    shared.tree = layout.Tree.init(alloc);
    shared.flat_alloc = alloc;
    defer shared.tree.deinit();
    defer if (shared.last_flat) |*f| f.deinit(alloc);
    defer if (shared.base_flat) |*f| f.deinit(alloc);
    try shared.tree.addFirst(0);
    try shared.tree.insert(0, 1);
    const tiles = try alloc.alloc(Tile, 2);
    defer alloc.free(tiles);
    var present = [_]bool{ true, true };
    for (tiles, 0..) |*t, i| {
        t.* = Tile{
            .r = .{ .target = .{ .sock = "/tmp/x" }, .label = "x", .session = "" },
            .rect = .{ .top = 0, .left = 0, .rows = 24, .cols = 80 },
            .shared = &shared,
            .idx = i,
            .wake_r = -1,
            .wake_w = -1,
        };
    }
    shared.fullscreen = true;
    wall_layout.relayout(fixture.wallAll(alloc, tiles, &present, &shared), 0);
    shared.fullscreen = false;
    wall_layout.relayout(fixture.wallAll(alloc, tiles, &present, &shared), 0);
    // Two tiles again: the bar (never gone on a tty) and both tiles'
    // real rects are back (stacked: full width, half height each).
    try std.testing.expectEqual(@as(u8, 1), shared.labelRows());
    try std.testing.expectEqual(@as(u16, 80), tiles[0].rect.cols);
    try std.testing.expectEqual(@as(u16, 80), tiles[1].rect.cols);
    try std.testing.expect(tiles[0].rect.rows < 24);
    try std.testing.expect(tiles[1].rect.rows > 0);
}

test "the label bar follows the tty, not the tile count" {
    // The bar is what names the session on screen, so hiding it when one
    // tile is visible left a zoomed or solo session anonymous. On a tty it
    // is always drawn — one tile, many, or fullscreen. A piped `mux` never
    // draws one: its byte stream is a script's input, and a bar in it
    // would be bytes the session never wrote.
    const alloc = std.testing.allocator;
    var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = true };
    shared.tree = layout.Tree.init(alloc);
    shared.flat_alloc = alloc;
    defer shared.tree.deinit();
    defer if (shared.last_flat) |*f| f.deinit(alloc);
    defer if (shared.base_flat) |*f| f.deinit(alloc);
    try shared.tree.addFirst(0);
    const tiles = try alloc.alloc(Tile, 1);
    defer alloc.free(tiles);
    var present = [_]bool{true};
    tiles[0] = Tile{
        .r = .{ .target = .{ .sock = "/tmp/x" }, .label = "x", .session = "" },
        .rect = .{ .top = 0, .left = 0, .rows = 24, .cols = 80 },
        .shared = &shared,
        .idx = 0,
        .wake_r = -1,
        .wake_w = -1,
    };
    // One tile on a tty: the bar stays, and the session runs a row short.
    wall_layout.relayout(fixture.wallAll(alloc, tiles, &present, &shared), 0);
    try std.testing.expectEqual(@as(u16, 1), shared.labelRows());
    try std.testing.expectEqual(@as(u16, 23), tiles[0].viewRows());
    // Fullscreen is still a tty view, so the zoomed tile keeps its name.
    shared.fullscreen = true;
    wall_layout.relayout(fixture.wallAll(alloc, tiles, &present, &shared), 0);
    try std.testing.expectEqual(@as(u16, 1), shared.labelRows());
    // A pipe draws no bar whatever the count.
    shared.fullscreen = false;
    shared.is_tty = false;
    wall_layout.relayout(fixture.wallAll(alloc, tiles, &present, &shared), 0);
    try std.testing.expectEqual(@as(u16, 0), shared.labelRows());
}

test "focus_dir while fullscreened follows the focus" {
    const alloc = std.testing.allocator;
    var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
    shared.tree = layout.Tree.init(alloc);
    shared.flat_alloc = alloc;
    defer shared.tree.deinit();
    defer if (shared.last_flat) |*f| f.deinit(alloc);
    defer if (shared.base_flat) |*f| f.deinit(alloc);
    try shared.tree.addFirst(0);
    try shared.tree.insert(0, 1);
    const tiles = try alloc.alloc(Tile, 2);
    defer alloc.free(tiles);
    var present = [_]bool{ true, true };
    for (tiles, 0..) |*t, i| {
        t.* = Tile{
            .r = .{ .target = .{ .sock = "/tmp/x" }, .label = "x", .session = "" },
            .rect = .{ .top = 0, .left = 0, .rows = 24, .cols = 80 },
            .shared = &shared,
            .idx = i,
            .wake_r = -1,
            .wake_w = -1,
        };
    }
    shared.fullscreen = true;
    wall_layout.relayout(fixture.wallAll(alloc, tiles, &present, &shared), 0);
    // Move focus to tile 1 while fullscreened.
    wv.focusAnswer(fixture.wallAll(alloc, tiles, &present, &shared), false, 1);
    // The full rect followed: tile 1 now owns the terminal.
    try std.testing.expectEqual(@as(u16, 24), tiles[1].rect.rows);
    try std.testing.expectEqual(@as(u16, 80), tiles[1].rect.cols);
    try std.testing.expectEqual(@as(u16, 0), tiles[0].rect.rows);
    try std.testing.expectEqual(@as(usize, 1), shared.sel);
}

test "resize: l grows the focused pane, h shrinks it" {
    // Spec: h shrinks width, l grows width. layout.resize always makes
    // the focus GAIN cells, so shrink = grow a neighbor at focus's
    // expense. Two beside panes at 80x24 (floors {3,2}): equal split is
    // 39/40 (rail takes 1). l moves the rail right; h moves it left.
    const alloc = std.testing.allocator;
    var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
    shared.tree = layout.Tree.init(alloc);
    shared.flat_alloc = alloc;
    defer shared.tree.deinit();
    defer if (shared.last_flat) |*f| f.deinit(alloc);
    defer if (shared.base_flat) |*f| f.deinit(alloc);
    try shared.tree.addFirst(0);
    try shared.tree.splitRight(0, 1);
    const tiles = try alloc.alloc(Tile, 2);
    defer alloc.free(tiles);
    var present = [_]bool{ true, true };
    for (tiles, 0..) |*t, i| {
        t.* = Tile{
            .r = .{ .target = .{ .sock = "/tmp/x" }, .label = "x", .session = "" },
            .rect = .{ .top = 0, .left = 0, .rows = 24, .cols = 80 },
            .shared = &shared,
            .idx = i,
            .wake_r = -1,
            .wake_w = -1,
        };
    }
    wall_layout.relayout(fixture.wallAll(alloc, tiles, &present, &shared), 0);
    const before = tiles[0].rect.cols;
    // l (grow width): focus 0 gains from its right sibling.
    try std.testing.expect(wall_layout.doResize(fixture.wallAll(alloc, tiles, &present, &shared), 0, .right));
    try std.testing.expect(tiles[0].rect.cols > before);
    // h (shrink width): the right neighbor gains a cell back from focus.
    try std.testing.expect(wall_layout.doResize(fixture.wallAll(alloc, tiles, &present, &shared), 0, .left));
    try std.testing.expectEqual(before, tiles[0].rect.cols);
}

test "resize: j grows height, k shrinks height" {
    const alloc = std.testing.allocator;
    var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
    shared.tree = layout.Tree.init(alloc);
    shared.flat_alloc = alloc;
    defer shared.tree.deinit();
    defer if (shared.last_flat) |*f| f.deinit(alloc);
    defer if (shared.base_flat) |*f| f.deinit(alloc);
    try shared.tree.addFirst(0);
    try shared.tree.splitBelow(0, 1);
    const tiles = try alloc.alloc(Tile, 2);
    defer alloc.free(tiles);
    var present = [_]bool{ true, true };
    for (tiles, 0..) |*t, i| {
        t.* = Tile{
            .r = .{ .target = .{ .sock = "/tmp/x" }, .label = "x", .session = "" },
            .rect = .{ .top = 0, .left = 0, .rows = 24, .cols = 80 },
            .shared = &shared,
            .idx = i,
            .wake_r = -1,
            .wake_w = -1,
        };
    }
    wall_layout.relayout(fixture.wallAll(alloc, tiles, &present, &shared), 0);
    const before = tiles[0].rect.rows;
    try std.testing.expect(wall_layout.doResize(fixture.wallAll(alloc, tiles, &present, &shared), 0, .down));
    try std.testing.expect(tiles[0].rect.rows > before);
    try std.testing.expect(wall_layout.doResize(fixture.wallAll(alloc, tiles, &present, &shared), 0, .up));
    try std.testing.expectEqual(before, tiles[0].rect.rows);
}

test "resize refuses while fullscreened" {
    const alloc = std.testing.allocator;
    var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
    shared.tree = layout.Tree.init(alloc);
    shared.flat_alloc = alloc;
    defer shared.tree.deinit();
    defer if (shared.last_flat) |*f| f.deinit(alloc);
    defer if (shared.base_flat) |*f| f.deinit(alloc);
    try shared.tree.addFirst(0);
    try shared.tree.splitRight(0, 1);
    const tiles = try alloc.alloc(Tile, 2);
    defer alloc.free(tiles);
    var present = [_]bool{ true, true };
    for (tiles, 0..) |*t, i| {
        t.* = Tile{
            .r = .{ .target = .{ .sock = "/tmp/x" }, .label = "x", .session = "" },
            .rect = .{ .top = 0, .left = 0, .rows = 24, .cols = 80 },
            .shared = &shared,
            .idx = i,
            .wake_r = -1,
            .wake_w = -1,
        };
    }
    shared.fullscreen = true;
    wall_layout.relayout(fixture.wallAll(alloc, tiles, &present, &shared), 0);
    try std.testing.expect(!wall_layout.doResize(fixture.wallAll(alloc, tiles, &present, &shared), 0, .right));
}

test "a pump's answer that grew the wall re-cuts it; a mere focus move does not" {
    const alloc = std.testing.allocator;
    var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = true };
    defer if (shared.last_flat) |*f| f.deinit(shared.flat_alloc);
    defer if (shared.base_flat) |*f| f.deinit(shared.flat_alloc);
    const tiles = try alloc.alloc(Tile, 2);
    defer alloc.free(tiles);
    const present = try alloc.alloc(bool, 2);
    defer alloc.free(present);
    present[0] = true;
    present[1] = true;
    for (tiles, 0..) |*t, i| {
        t.* = Tile{
            .r = .{ .target = .{ .sock = "/tmp/x" }, .label = "x", .session = "" },
            .rect = .{ .top = 0, .left = 0, .rows = 24, .cols = 80 },
            .shared = &shared,
            .idx = i,
            .wake_r = -1,
            .wake_w = -1,
        };
    }
    // Growth: without the re-cut the new tile sits on placeholder geometry
    // and the old tile still owns the whole terminal — the screen keeps
    // showing tile 0 while the keyboard types into tile 1's rows.
    wv.focusAnswer(fixture.wallAll(alloc, tiles, present, &shared), true, 1);
    try std.testing.expectEqual(@as(u8, 1), shared.labelRows());
    try std.testing.expect(tiles[0].resize_pending);
    try std.testing.expect(tiles[1].resize_pending);
    try std.testing.expectEqual(@as(usize, 1), shared.sel);
    try std.testing.expect(tiles[1].claim_pending.load(.acquire));
    tiles[0].resize_pending = false;
    tiles[1].resize_pending = false;
    // No growth: a full clear here would flash the wall for a focus move.
    wv.focusAnswer(fixture.wallAll(alloc, tiles, present, &shared), false, 0);
    try std.testing.expect(!tiles[0].resize_pending);
    try std.testing.expect(!tiles[1].resize_pending);
    try std.testing.expectEqual(@as(usize, 0), shared.sel);
    try std.testing.expect(tiles[0].claim_pending.load(.acquire));
}

test "a piped wall draws no label bar and paints row 1" {
    const alloc = std.testing.allocator;
    var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
    shared.tree = layout.Tree.init(alloc);
    shared.flat_alloc = alloc;
    defer shared.tree.deinit();
    defer if (shared.last_flat) |*f| f.deinit(alloc);
    defer if (shared.base_flat) |*f| f.deinit(alloc);
    try shared.tree.addFirst(0);
    const tiles = try alloc.alloc(Tile, 1);
    defer alloc.free(tiles);
    const present = try alloc.alloc(bool, 1);
    defer alloc.free(present);
    present[0] = true;
    tiles[0] = Tile{
        .r = .{ .target = .{ .sock = "/tmp/x" }, .label = "x", .session = "" },
        .rect = .{ .top = 0, .left = 0, .rows = 24, .cols = 80 },
        .shared = &shared,
        .idx = 0,
        .wake_r = -1,
        .wake_w = -1,
    };
    wall_layout.relayout(fixture.wallAll(alloc, tiles, present, &shared), 0);
    // A pipe: no label bar, every row is content the script can read.
    try std.testing.expectEqual(@as(u8, 0), shared.labelRows());
    try std.testing.expectEqual(@as(u16, 24), tiles[0].viewRows());
}

test "a relayout drops every tile's highlight, because the stripes move under it" {
    // The drag is per-tile now, on each pump's Core, so relayout cannot
    // reach it directly. It bumps repaint_gen instead, and every pump
    // clears its own core.drag when it sees the generation move under it —
    // a relayout re-cut the rect a held drag's anchor was resolved against.
    // The generation is the whole mechanism, so it is what this pins.
    const alloc = std.testing.allocator;
    var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
    shared.tree = layout.Tree.init(alloc);
    shared.flat_alloc = alloc;
    defer shared.tree.deinit();
    defer if (shared.last_flat) |*f| f.deinit(alloc);
    defer if (shared.base_flat) |*f| f.deinit(alloc);
    try shared.tree.addFirst(0);
    try shared.tree.insert(0, 1);
    const tiles = try alloc.alloc(Tile, 2);
    defer alloc.free(tiles);
    const present = try alloc.alloc(bool, 2);
    defer alloc.free(present);
    present[0] = true;
    present[1] = true;
    for (tiles, 0..) |*t, i| {
        t.* = Tile{
            .r = .{ .target = .{ .sock = "/tmp/x" }, .label = "x", .session = "" },
            .rect = .{ .top = 0, .left = 0, .rows = 24, .cols = 80 },
            .shared = &shared,
            .idx = i,
            .wake_r = -1,
            .wake_w = -1,
        };
    }
    const before = shared.repaint_gen.load(.acquire);
    wall_layout.relayout(fixture.wallAll(alloc, tiles, present, &shared), 0);
    // One bump, every tile's pump clears its drag on it: the stripes moved,
    // so a held drag's anchor names a rect that is somewhere else now.
    try std.testing.expect(shared.repaint_gen.load(.acquire) > before);
}

test "saveLayoutTo writes the sidecar for the present tiles, spellings verbatim" {
    // A wall saves its tree on the way out: the leaf spellings are the
    // tiles' labels verbatim, so a reload restores the same view.
    const alloc = std.testing.allocator;
    var tmp = try TmpDir.make();
    defer tmp.cleanup();
    const path = try std.fmt.allocPrint(alloc, "{s}/layout", .{tmp.path()});
    defer alloc.free(path);
    var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
    shared.tree = layout.Tree.init(alloc);
    shared.flat_alloc = alloc;
    defer shared.tree.deinit();
    defer if (shared.last_flat) |*f| f.deinit(alloc);
    defer if (shared.base_flat) |*f| f.deinit(alloc);
    try shared.tree.addFirst(0);
    try shared.tree.splitRight(0, 1);
    const tiles = try alloc.alloc(Tile, 2);
    defer alloc.free(tiles);
    for (tiles, 0..) |*t, i| {
        t.* = Tile{
            .r = .{
                .target = .{ .sock = "/tmp/x" },
                .label = switch (i) {
                    0 => "--sock /tmp/x#a",
                    else => "--sock /tmp/x#b",
                },
                .session = "",
            },
            .rect = .{ .top = 0, .left = 0, .rows = 24, .cols = 80 },
            .shared = &shared,
            .idx = i,
            .wake_r = -1,
            .wake_w = -1,
        };
    }
    var present = [_]bool{ true, true };
    try wall_layout.saveLayoutTo(alloc, path, tiles, &present, &shared);
    const got = wall_layout.loadLayout(alloc, path) orelse return error.TestUnexpectedResult;
    defer alloc.free(got);
    try std.testing.expect(std.mem.startsWith(u8, got, "mux-layout 1\nbeside 0\n"));
    try std.testing.expect(std.mem.indexOf(u8, got, "--sock /tmp/x#b\n") != null);
}

test "saveLayoutTo handles a vanished middle tile without panicking" {
    // Tiles are never compacted (pump threads hold pointers into the
    // array), so a vanished middle tile leaves a hole: present = {t,f,t}
    // and the tree holds leaves 0 and 2. serialize indexes spellings by
    // leaf ID, so the array must be tile-indexed — a dense array would
    // panic on spellings[2] with only two entries.
    const alloc = std.testing.allocator;
    var tmp = try TmpDir.make();
    defer tmp.cleanup();
    const path = try std.fmt.allocPrint(alloc, "{s}/layout", .{tmp.path()});
    defer alloc.free(path);
    var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
    shared.tree = layout.Tree.init(alloc);
    shared.flat_alloc = alloc;
    defer shared.tree.deinit();
    defer if (shared.last_flat) |*f| f.deinit(alloc);
    defer if (shared.base_flat) |*f| f.deinit(alloc);
    try shared.tree.addFirst(0);
    try shared.tree.splitRight(0, 1);
    try shared.tree.splitRight(1, 2);
    // Tile 1 vanished: remove leaf 1 from the tree.
    shared.tree.remove(1);
    const tiles = try alloc.alloc(Tile, 3);
    defer alloc.free(tiles);
    for (tiles, 0..) |*t, i| {
        t.* = Tile{
            .r = .{
                .target = .{ .sock = "/tmp/x" },
                .label = switch (i) {
                    0 => "--sock /tmp/x#a",
                    1 => "--sock /tmp/x#b",
                    else => "--sock /tmp/x#c",
                },
                .session = "",
            },
            .rect = .{ .top = 0, .left = 0, .rows = 24, .cols = 80 },
            .shared = &shared,
            .idx = i,
            .wake_r = -1,
            .wake_w = -1,
        };
    }
    var present = [_]bool{ true, false, true };
    try wall_layout.saveLayoutTo(alloc, path, tiles, &present, &shared);
    const got = wall_layout.loadLayout(alloc, path) orelse return error.TestUnexpectedResult;
    defer alloc.free(got);
    // Tile 2's label is verbatim; the hole (tile 1) is never serialized.
    try std.testing.expect(std.mem.indexOf(u8, got, "--sock /tmp/x#c\n") != null);
    try std.testing.expect(std.mem.indexOf(u8, got, "--sock /tmp/x#b") == null);
}

test "layout sidecar: save round-trips through load; a missing file is null" {
    const alloc = std.testing.allocator;
    var tmp = try TmpDir.make();
    defer tmp.cleanup();
    const path = try std.fmt.allocPrint(alloc, "{s}/layout", .{tmp.path()});
    defer alloc.free(path);
    try std.testing.expect(wall_layout.loadLayout(alloc, path) == null);
    try hosts.saveBytes(path, "mux-layout 1\nleaf 0 x\n");
    const got = wall_layout.loadLayout(alloc, path) orelse return error.TestUnexpectedResult;
    defer alloc.free(got);
    try std.testing.expectEqualStrings("mux-layout 1\nleaf 0 x\n", got);
}

test "layoutPathFrom: the sidecar sits beside the hosts file" {
    const alloc = std.testing.allocator;
    const p = try hosts.layoutPathFrom(alloc, "/xdg", null);
    defer alloc.free(p);
    try std.testing.expectEqualStrings("/xdg/mux/layout", p);
    const q = try hosts.layoutPathFrom(alloc, null, "/home/u");
    defer alloc.free(q);
    try std.testing.expectEqualStrings("/home/u/.local/state/mux/layout", q);
}

// ---- the seed: the sidecar's cut before any host has answered ----

/// Two hosts and a three-leaf sidecar — beside(a, stacked(b, c)) with
/// UNEQUAL weights — so orientation, weight and host aliasing are all
/// dimensions every seed test exercises by default.
const seed_bytes = "mux-layout 1\nbeside 0\n leaf 70 --sock /tmp/h0.sock#a\n stacked 29\n  leaf 20 --sock /tmp/h0.sock#b\n  leaf 8 --sock /tmp/h1.sock#a\nfocus 1\n";

fn seedShared(alloc: std.mem.Allocator, shared: *Shared) void {
    shared.* = Shared{ .out_fd = -1, .size = .{ .cols = 100, .rows = 30 }, .is_tty = false };
    shared.tree = layout.Tree.init(alloc);
    shared.flat_alloc = alloc;
}

test "seed: the saved cut is the tree before any host has answered - orientation and weights both" {
    const alloc = std.testing.allocator;
    var shared: Shared = undefined;
    seedShared(alloc, &shared);
    defer shared.tree.deinit();
    var table = [_]wall_host.Host{
        fixture.testHost(&shared, "--sock /tmp/h0.sock", "/tmp/h0.sock"),
        fixture.testHost(&shared, "--sock /tmp/h1.sock", "/tmp/h1.sock"),
    };
    var res = wall_layout.seedLayout(alloc, &table, &shared, seed_bytes, null);
    defer if (res == .plan) res.plan.deinit(alloc);
    try std.testing.expect(res == .plan);
    const plan = res.plan;
    try std.testing.expectEqual(@as(usize, 3), plan.panes.len);
    try std.testing.expectEqual(@as(usize, 0), plan.dropped);
    try std.testing.expectEqual(@as(usize, 0), plan.panes[0].?.host);
    try std.testing.expectEqualStrings("a", plan.panes[0].?.session);
    try std.testing.expectEqual(@as(usize, 1), plan.panes[2].?.host);
    var f = try shared.tree.flatten(alloc, 30, 100, wall_layout.wallFloors(true), null);
    defer f.deinit(alloc);
    // The saved orientation: pane 0 beside the stack of 1 over 2. The old
    // heal lost both to the default cut; the rects are the pin.
    const r0 = f.rectOf(0).?;
    const r1 = f.rectOf(1).?;
    const r2 = f.rectOf(2).?;
    try std.testing.expect(r1.left > 0 and r1.top == 0);
    try std.testing.expect(r2.left == r1.left and r2.top > 0);
    // The saved weights: 70/29 is not an even cut.
    try std.testing.expect(r0.cols > 2 * r1.cols);
}

test "seed: the session this shell is standing in is never seeded" {
    const alloc = std.testing.allocator;
    var shared: Shared = undefined;
    seedShared(alloc, &shared);
    defer shared.tree.deinit();
    var table = [_]wall_host.Host{
        fixture.testHost(&shared, "--sock /tmp/h0.sock", "/tmp/h0.sock"),
        fixture.testHost(&shared, "--sock /tmp/h1.sock", "/tmp/h1.sock"),
    };
    table[0].self_name = "a";
    // The one leaf a good file can name and this wall still not seat: it
    // is a drop, counted, and not a refusal of the file around it.
    var res = wall_layout.seedLayout(alloc, &table, &shared, seed_bytes, null);
    defer if (res == .plan) res.plan.deinit(alloc);
    try std.testing.expect(res == .plan);
    const plan = res.plan;
    try std.testing.expectEqual(@as(usize, 2), plan.panes.len);
    try std.testing.expectEqual(@as(usize, 1), plan.dropped);
    // Host 1's aliasing "a" is another host's session and stays.
    try std.testing.expectEqualStrings("--sock /tmp/h0.sock#b", plan.panes[0].?.label);
    try std.testing.expectEqualStrings("--sock /tmp/h1.sock#a", plan.panes[1].?.label);
}

test "seed: a file naming nothing but this shell's own sessions is self_only, not the silence of an empty one" {
    const alloc = std.testing.allocator;
    var shared: Shared = undefined;
    seedShared(alloc, &shared);
    defer shared.tree.deinit();
    var table = [_]wall_host.Host{
        fixture.testHost(&shared, "--sock /tmp/h0.sock", "/tmp/h0.sock"),
        fixture.testHost(&shared, "--sock /tmp/h1.sock", "/tmp/h1.sock"),
    };
    // Plural and off-origin: BOTH hosts are the shell's own, so nothing is
    // left after the drops and the answer cannot come from a single leaf.
    table[0].self_name = "a";
    table[1].self_name = "a";
    const all_self = "mux-layout 1\nbeside 0\n leaf 50 --sock /tmp/h0.sock#a\n leaf 50 --sock /tmp/h1.sock#a\n";
    // Told apart from `.none` because the caller acts differently: this file
    // is a wall the user authored and this run must not write over it, while
    // an empty file has nothing to lose.
    try std.testing.expect(wall_layout.seedLayout(alloc, &table, &shared, all_self, null) == .self_only);
    try std.testing.expect(wall_layout.seedLayout(alloc, &table, &shared, "   \n\t\n", null) == .none);
    // One leaf that is not this shell's is a plan again, with the other
    // counted as dropped - the distinction is "none seated", not "any self".
    const one_free = "mux-layout 1\nbeside 0\n leaf 50 --sock /tmp/h0.sock#a\n leaf 50 --sock /tmp/h1.sock#b\n";
    var res = wall_layout.seedLayout(alloc, &table, &shared, one_free, null);
    defer if (res == .plan) res.plan.deinit(alloc);
    try std.testing.expect(res == .plan);
    try std.testing.expectEqual(@as(usize, 1), res.plan.dropped_self);
}

test "refusalNotice: the sentence names the line and is cut to what a notice holds" {
    var buf: [96]u8 = undefined;
    const short = wv.refusalNotice(&buf, "box#0");
    try std.testing.expectEqualStrings("[layout not saved: the layout file was refused - fix box#0]", short);
    // A leaf spelling can be longer than the whole notice; the head is what
    // says what happened, so the tail of the line is what goes.
    const long = wv.refusalNotice(&buf, "--sock /tmp/a/very/long/socket/path/that/nobody/would/type.sock#work");
    try std.testing.expect(long.len <= buf.len);
    try std.testing.expect(std.mem.startsWith(u8, long, "[layout not saved: the layout file was refused - fix --sock /tmp/a"));
    try std.testing.expect(std.mem.endsWith(u8, long, "]"));
}

test "seed: garbage and a leaf no host on this wall can seat refuse the file; an empty file is silence - and the tree survives all three" {
    const alloc = std.testing.allocator;
    var shared: Shared = undefined;
    seedShared(alloc, &shared);
    defer shared.tree.deinit();
    try shared.tree.addFirst(7);
    var table = [_]wall_host.Host{
        fixture.testHost(&shared, "--sock /tmp/other.sock", "/tmp/other.sock"),
    };
    // Text that is not a layout is named by its first line; a layout whose
    // first leaf names a host this wall does not have is named by that leaf.
    const junk = wall_layout.seedLayout(alloc, &table, &shared, "not a layout at all", null);
    try std.testing.expect(junk == .refused);
    try std.testing.expectEqualStrings("not a layout at all", junk.refused);
    const unseatable = wall_layout.seedLayout(alloc, &table, &shared, seed_bytes, null);
    try std.testing.expect(unseatable == .refused);
    try std.testing.expectEqualStrings("--sock /tmp/h0.sock#a", unseatable.refused);
    // An empty file was never authored: there is no line to print and
    // nothing for the user to fix, so it degrades in silence.
    try std.testing.expect(wall_layout.seedLayout(alloc, &table, &shared, "", null) == .none);
    try std.testing.expectEqual(@as(usize, 1), shared.tree.count());
}

test "seed: more leaves than the terminal can cut seeds what fits and counts the rest" {
    const alloc = std.testing.allocator;
    var shared: Shared = undefined;
    seedShared(alloc, &shared);
    // Nine stacked panes at 3 rows each need 27; give them 13 rows, which
    // holds four. The boot must not refuse - relayout's TooSmall degrade
    // never runs on the first flatten, so the seed carries its own.
    // A tty, as every restoring wall is: only a terminal saves a sidecar,
    // and the bar row is part of what each pane must clear.
    shared.is_tty = true;
    shared.size = .{ .cols = 100, .rows = 13 };
    defer shared.tree.deinit();
    var table = [_]wall_host.Host{
        fixture.testHost(&shared, "--sock /tmp/h0.sock", "/tmp/h0.sock"),
    };
    var buf = std.ArrayListUnmanaged(u8){};
    defer buf.deinit(alloc);
    try buf.appendSlice(alloc, "mux-layout 1\nstacked 0\n");
    for (0..9) |i| try buf.writer(alloc).print(" leaf 10 --sock /tmp/h0.sock#s{d}\n", .{i});
    var res = wall_layout.seedLayout(alloc, &table, &shared, buf.items, null);
    defer if (res == .plan) res.plan.deinit(alloc);
    try std.testing.expect(res == .plan);
    try std.testing.expectEqual(@as(usize, 4), res.plan.panes.len);
    try std.testing.expectEqual(@as(usize, 5), res.plan.dropped);
    try std.testing.expectEqual(@as(usize, 4), shared.tree.count());
}

test "seed: more leaves than the wall seats refuses the file, naming the first leaf past the cap" {
    const alloc = std.testing.allocator;
    var shared: Shared = undefined;
    seedShared(alloc, &shared);
    // Tall enough that every leaf would fit: the cap under test is the
    // wall's, not the terminal's.
    shared.size = .{ .cols = 100, .rows = 500 };
    defer shared.tree.deinit();
    var table = [_]wall_host.Host{
        fixture.testHost(&shared, "--sock /tmp/h0.sock", "/tmp/h0.sock"),
    };
    var buf = std.ArrayListUnmanaged(u8){};
    defer buf.deinit(alloc);
    try buf.appendSlice(alloc, "mux-layout 1\nstacked 0\n");
    for (0..40) |i| try buf.writer(alloc).print(" leaf 10 --sock /tmp/h0.sock#s{d}\n", .{i});
    const res = wall_layout.seedLayout(alloc, &table, &shared, buf.items, null);
    try std.testing.expect(res == .refused);
    var want_buf: [64]u8 = undefined;
    const want = try std.fmt.bufPrint(&want_buf, "--sock /tmp/h0.sock#s{d}", .{wv.max_tiles});
    try std.testing.expectEqualStrings(want, res.refused);
    // Seating the first 32 of a 40-pane wall is not the wall that was
    // saved, so nothing is seated at all.
    try std.testing.expectEqual(@as(usize, 0), shared.tree.count());
}

test "seed: the saved focus comes back as the pane that carries it, and is null when that leaf was dropped" {
    const alloc = std.testing.allocator;
    var shared: Shared = undefined;
    seedShared(alloc, &shared);
    defer shared.tree.deinit();
    var table = [_]wall_host.Host{
        fixture.testHost(&shared, "--sock /tmp/h0.sock", "/tmp/h0.sock"),
        fixture.testHost(&shared, "--sock /tmp/h1.sock", "/tmp/h1.sock"),
    };
    var res = wall_layout.seedLayout(alloc, &table, &shared, seed_bytes, null);
    try std.testing.expect(res == .plan);
    try std.testing.expectEqual(@as(?usize, 1), res.plan.focus);
    res.plan.deinit(alloc);
    // The focused leaf (saved index 1, host 0's "b") is the session this
    // shell is standing in, so it is dropped: nothing carries the record.
    shared.tree.deinit();
    shared.tree = layout.Tree.init(alloc);
    table[0].self_name = "b";
    var res2 = wall_layout.seedLayout(alloc, &table, &shared, seed_bytes, null);
    defer if (res2 == .plan) res2.plan.deinit(alloc);
    try std.testing.expect(res2 == .plan);
    try std.testing.expectEqual(@as(?usize, null), res2.plan.focus);
}

test "seed: the entry tile's own pane is not pending; a sidecar that does not know it inserts it" {
    const alloc = std.testing.allocator;
    var shared: Shared = undefined;
    seedShared(alloc, &shared);
    defer shared.tree.deinit();
    var table = [_]wall_host.Host{
        fixture.testHost(&shared, "--sock /tmp/h0.sock", "/tmp/h0.sock"),
        fixture.testHost(&shared, "--sock /tmp/h1.sock", "/tmp/h1.sock"),
    };
    // Matched: the entry stands where its saved pane was, id 0, not
    // pending, and no second pane claims its (host, session).
    var res = wall_layout.seedLayout(alloc, &table, &shared, seed_bytes, "--sock /tmp/h0.sock#a");
    try std.testing.expect(res == .plan);
    try std.testing.expectEqual(@as(usize, 3), res.plan.panes.len);
    try std.testing.expectEqual(@as(?wall_layout.SeedPane, null), res.plan.panes[0]);
    try std.testing.expect(res.plan.panes[1] != null and res.plan.panes[2] != null);
    try std.testing.expectEqual(@as(usize, 3), shared.tree.count());
    res.plan.deinit(alloc);
    // Unmatched: the sidecar predates this entry; its pane is inserted
    // beside the saved focus, and every saved leaf is still a pane.
    shared.tree.deinit();
    shared.tree = layout.Tree.init(alloc);
    var res2 = wall_layout.seedLayout(alloc, &table, &shared, seed_bytes, "--sock /tmp/h9.sock#z");
    defer if (res2 == .plan) res2.plan.deinit(alloc);
    try std.testing.expect(res2 == .plan);
    try std.testing.expectEqual(@as(usize, 4), res2.plan.panes.len);
    try std.testing.expectEqual(@as(?wall_layout.SeedPane, null), res2.plan.panes[0]);
    try std.testing.expectEqual(@as(usize, 4), shared.tree.count());
}

test "a wall left before its hosts answered saves the shape it was given" {
    const alloc = std.testing.allocator;
    var shared: Shared = undefined;
    seedShared(alloc, &shared);
    defer shared.tree.deinit();
    var tiles: [2]Tile = undefined;
    var present = [_]bool{ true, true };
    try shared.tree.addFirst(0);
    try shared.tree.insert(0, 1);
    for (0..2) |i| {
        try wv.seedTile(&tiles[i], .{
            .target = .{ .sock = "/tmp/h0.sock" },
            .label = if (i == 0)
                try alloc.dupe(u8, "--sock /tmp/h0.sock#a")
            else
                try alloc.dupe(u8, "--sock /tmp/h0.sock#b"),
            .session = try alloc.dupe(u8, if (i == 0) "a" else "b"),
        }, .{ .top = 0, .left = 0, .rows = 12, .cols = 100 }, &shared, i, 0);
    }
    defer for (tiles[0..2]) |*t| {
        alloc.free(t.r.label);
        alloc.free(t.r.session);
        if (t.wake_r >= 0) std.posix.close(t.wake_r);
        if (t.wake_w >= 0) std.posix.close(t.wake_w);
    };
    var tmp = try TmpDir.make();
    defer tmp.cleanup();
    var pbuf: [256]u8 = undefined;
    const path = try std.fmt.bufPrint(&pbuf, "{s}/layout", .{tmp.path()});
    // Detaching inside the settle window: the pending panes' spellings go
    // back out verbatim, so a quick in-and-out does not erode the sidecar.
    try wall_layout.saveLayoutTo(alloc, path, &tiles, &present, &shared);
    const bytes = wall_layout.loadLayout(alloc, path) orelse return error.NothingSaved;
    defer alloc.free(bytes);
    try std.testing.expect(std.mem.indexOf(u8, bytes, "--sock /tmp/h0.sock#a") != null);
    try std.testing.expect(std.mem.indexOf(u8, bytes, "--sock /tmp/h0.sock#b") != null);
}

test "seedLayout: every leaf of a good file is a pane, in the file's tree, and nothing else is consulted" {
    var shared = Shared{ .out_fd = -1, .size = .{ .cols = 120, .rows = 40 }, .is_tty = true };
    defer shared.tree.deinit();
    var table = [_]Host{
        fixture.testHost(&shared, "--sock /a", "/a"),
        fixture.testHost(&shared, "box", "/b"),
    };
    // No poll answer on either host: the file alone decides.
    const file =
        \\mux-layout 1
        \\beside 0
        \\ leaf 1 --sock /a#0
        \\ leaf 1 box#work
        \\ leaf 1 --sock /a#2
        \\focus 1
        \\
    ;
    var res = wall_layout.seedLayout(std.testing.allocator, &table, &shared, file, null);
    defer if (res == .plan) res.plan.deinit(std.testing.allocator);
    try std.testing.expect(res == .plan);
    try std.testing.expectEqual(@as(usize, 3), res.plan.panes.len);
    try std.testing.expectEqualStrings("0", res.plan.panes[0].?.session);
    try std.testing.expectEqual(@as(usize, 0), res.plan.panes[0].?.host);
    try std.testing.expectEqualStrings("work", res.plan.panes[1].?.session);
    try std.testing.expectEqual(@as(usize, 1), res.plan.panes[1].?.host);
    try std.testing.expectEqual(@as(?usize, 1), res.plan.focus);
    try std.testing.expectEqual(@as(usize, 0), res.plan.dropped);
    try std.testing.expectEqual(@as(usize, 3), shared.tree.count());
}

test "seedLayout: a leaf whose host is not in the hosts file refuses the whole file and names the line" {
    var shared = Shared{ .out_fd = -1, .size = .{ .cols = 120, .rows = 40 }, .is_tty = true };
    defer shared.tree.deinit();
    var table = [_]Host{fixture.testHost(&shared, "--sock /a", "/a")};
    const file =
        \\mux-layout 1
        \\beside 0
        \\ leaf 1 --sock /a#0
        \\ leaf 1 nowhere#0
        \\
    ;
    const res = wall_layout.seedLayout(std.testing.allocator, &table, &shared, file, null);
    try std.testing.expect(res == .refused);
    try std.testing.expectEqualStrings("nowhere#0", res.refused);
    // Nothing was seated: the caller starts as if the file were missing.
    try std.testing.expect(shared.tree.root == null);
}

test "seedLayout: a leaf with no session, a bad name, or a repeat refuses; garbage refuses with its first line" {
    var shared = Shared{ .out_fd = -1, .size = .{ .cols = 120, .rows = 40 }, .is_tty = true };
    defer shared.tree.deinit();
    var table = [_]Host{fixture.testHost(&shared, "--sock /a", "/a")};
    const no_session = "mux-layout 1\nleaf 0 --sock /a\n";
    const bad_name = "mux-layout 1\nleaf 0 --sock /a#no space\n";
    const repeat = "mux-layout 1\nbeside 0\n leaf 1 --sock /a#0\n leaf 1 --sock /a#0\n";
    const garbage = "not a layout\n";
    for ([_][]const u8{ no_session, bad_name, repeat, garbage }) |file| {
        const res = wall_layout.seedLayout(std.testing.allocator, &table, &shared, file, null);
        try std.testing.expect(res == .refused);
        try std.testing.expect(res.refused.len > 0);
    }
    const on_repeat = wall_layout.seedLayout(std.testing.allocator, &table, &shared, repeat, null);
    try std.testing.expect(on_repeat == .refused);
    try std.testing.expectEqualStrings("--sock /a#0", on_repeat.refused);
    const on_garbage = wall_layout.seedLayout(std.testing.allocator, &table, &shared, garbage, null);
    try std.testing.expect(on_garbage == .refused);
    try std.testing.expectEqualStrings("not a layout", on_garbage.refused);
}

test "seedLayout: a --via leaf refuses the whole file, which is why a --via wall records nothing" {
    const alloc = std.testing.allocator;
    var shared = Shared{ .out_fd = -1, .size = .{ .cols = 120, .rows = 40 }, .is_tty = true };
    defer shared.tree.deinit();
    // The leaf a `--via` entry would be saved as. `tileLabel` is the one
    // speller of a leaf, so the file below is byte for byte the file the
    // start-up `persist` used to produce for `mux --via CMD`.
    const label = try wv.tileLabel(alloc, .{ .via = "ssh box mux d proxy" }, "0");
    defer alloc.free(label);
    try std.testing.expectEqualStrings("--via ssh box mux d proxy#0", label);
    // `hosts.zig` refuses to write a `--via` line, so no hosts table can
    // ever have a row for one: this leaf names a host that cannot be there.
    var table = [_]Host{fixture.testHost(&shared, "--sock /a", "/a")};
    var file: std.ArrayListUnmanaged(u8) = .{};
    defer file.deinit(alloc);
    try file.appendSlice(alloc, "mux-layout 1\nbeside 0\n leaf 1 --sock /a#0\n leaf 1 ");
    try file.appendSlice(alloc, label);
    try file.append(alloc, '\n');
    const res = wall_layout.seedLayout(alloc, &table, &shared, file.items, null);
    try std.testing.expect(res == .refused);
    try std.testing.expectEqualStrings(label, res.refused);
    // The refusal is the WHOLE file: the pane on `--sock /a`, and every
    // other pane the user authored, goes with it. That is the cost `run`
    // avoids by nulling `Shared.layout_path` for a `--via` entry — it
    // neither reads the file nor writes one.
    try std.testing.expect(shared.tree.root == null);
}

test "seedLayout: the entry spelling takes leaf 0 when the file has it, and is inserted beside the focus when it does not" {
    var shared = Shared{ .out_fd = -1, .size = .{ .cols = 120, .rows = 40 }, .is_tty = true };
    defer shared.tree.deinit();
    var table = [_]Host{fixture.testHost(&shared, "--sock /a", "/a")};
    const file = "mux-layout 1\nbeside 0\n leaf 1 --sock /a#0\n leaf 1 --sock /a#1\nfocus 1\n";
    var has = wall_layout.seedLayout(std.testing.allocator, &table, &shared, file, "--sock /a#1");
    defer if (has == .plan) has.plan.deinit(std.testing.allocator);
    try std.testing.expect(has == .plan);
    // The entry is pane 0 by contract; the other leaf follows.
    try std.testing.expect(has.plan.panes[0] == null); // the entry tile is the caller's
    try std.testing.expectEqualStrings("0", has.plan.panes[1].?.session);
    try std.testing.expectEqual(@as(usize, 2), shared.tree.count());

    shared.tree.deinit();
    shared.tree = layout.Tree.init(std.testing.allocator);
    var not = wall_layout.seedLayout(std.testing.allocator, &table, &shared, file, "--sock /a#9");
    defer if (not == .plan) not.plan.deinit(std.testing.allocator);
    try std.testing.expect(not == .plan);
    try std.testing.expectEqual(@as(usize, 3), shared.tree.count());
}

test "seedLayout: a file that spells the entry tile twice is a repeat like any other, and refuses" {
    var shared = Shared{ .out_fd = -1, .size = .{ .cols = 120, .rows = 40 }, .is_tty = true };
    defer shared.tree.deinit();
    var table = [_]Host{fixture.testHost(&shared, "--sock /a", "/a")};
    // Only the FIRST leaf can be the tile the user is already typing in;
    // seating the second would put two panes on one session.
    const file = "mux-layout 1\nbeside 0\n leaf 1 --sock /a#1\n leaf 1 --sock /a#1\n";
    const res = wall_layout.seedLayout(std.testing.allocator, &table, &shared, file, "--sock /a#1");
    try std.testing.expect(res == .refused);
    try std.testing.expectEqualStrings("--sock /a#1", res.refused);
    try std.testing.expect(shared.tree.root == null);
}

test "seedLayout: a file good down to its last line is refused BY that line, not by its header" {
    var shared = Shared{ .out_fd = -1, .size = .{ .cols = 120, .rows = 40 }, .is_tty = true };
    defer shared.tree.deinit();
    var table = [_]Host{fixture.testHost(&shared, "--sock /a", "/a")};
    // `focus 9` names a leaf this file does not have, and the parse only
    // finds it after the walk. A reader told "mux-layout 1" would go and
    // fix the one line that is correct.
    const file = "mux-layout 1\nleaf 0 --sock /a#0\nfocus 9\n";
    const res = wall_layout.seedLayout(std.testing.allocator, &table, &shared, file, null);
    try std.testing.expect(res == .refused);
    try std.testing.expectEqualStrings("focus 9", res.refused);
}

test "seedLayout: a terminal too small for even one saved pane leaves the wall's own tree alone" {
    const alloc = std.testing.allocator;
    var shared: Shared = undefined;
    seedShared(alloc, &shared);
    // A tty, as every restoring wall is, so the label row counts toward
    // each pane's floor: two rows cannot hold ONE pane, let alone two.
    shared.is_tty = true;
    shared.size = .{ .cols = 100, .rows = 2 };
    defer shared.tree.deinit();
    // Something for the seed to lose. A trim that ran out of panes and
    // installed the empty remainder would leave a count of 0 here - which
    // an untouched fresh wall also has, so the pin needs a tree first.
    try shared.tree.addFirst(7);
    var table = [_]Host{fixture.testHost(&shared, "--sock /a", "/a")};
    const file = "mux-layout 1\nstacked 0\n leaf 1 --sock /a#0\n leaf 1 --sock /a#1\n";
    const res = wall_layout.seedLayout(alloc, &table, &shared, file, null);
    try std.testing.expect(res == .none);
    try std.testing.expectEqual(@as(usize, 1), shared.tree.count());
}

test "seedLayout: a terminal that holds one pane keeps the entry tile when the file's other leaves will not fit" {
    const alloc = std.testing.allocator;
    var shared: Shared = undefined;
    seedShared(alloc, &shared);
    // Three rows under a label bar: one pane clears the daemon's floor,
    // two do not, so the trim runs down to the entry tile's own leaf.
    shared.is_tty = true;
    shared.size = .{ .cols = 100, .rows = 3 };
    defer shared.tree.deinit();
    var table = [_]Host{fixture.testHost(&shared, "--sock /a", "/a")};
    const file = "mux-layout 1\nstacked 0\n leaf 1 --sock /a#0\n leaf 1 --sock /a#1\n";
    var res = wall_layout.seedLayout(alloc, &table, &shared, file, "--sock /a#0");
    defer if (res == .plan) res.plan.deinit(alloc);
    try std.testing.expect(res == .plan);
    try std.testing.expectEqual(@as(usize, 1), res.plan.panes.len);
    // The one pane is the entry tile's slot, which the caller fills.
    try std.testing.expect(res.plan.panes[0] == null);
    try std.testing.expectEqual(@as(usize, 1), res.plan.dropped);
    try std.testing.expectEqual(@as(usize, 1), shared.tree.count());
}

test "persist: a birth and a vanish each write the layout, and no layout_path writes nothing" {
    // An arena: a birth dupes the tile's own session and label, and the wall
    // frees those only when a later birth takes the digit back.
    var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer arena.deinit();
    const alloc = arena.allocator();
    var tmp = try TmpDir.make();
    defer tmp.cleanup();
    var path_buf: [std.fs.max_path_bytes]u8 = undefined;
    const path = try std.fmt.bufPrint(&path_buf, "{s}/layout", .{tmp.path()});

    var shared: Shared = undefined;
    fixture.stoppedWall(alloc, &shared);
    shared.size = .{ .cols = 120, .rows = 40 };
    var tiles: [wv.max_tiles]Tile = undefined;
    var present = [_]bool{false} ** wv.max_tiles;
    var live: usize = 0;
    defer fixture.endPumps(tiles[0..live]);
    var hosts_table = [_]Host{fixture.testHost(&shared, "--sock /a", "/a")};
    const w = fixture.wallOf(alloc, &tiles, &present, &live, &shared, &hosts_table);

    // Not yet a wall that persists: nothing is written.
    wall_layout.persist(w);
    try std.testing.expectError(error.FileNotFound, std.fs.cwd().access(path, .{}));

    shared.layout_path = path;
    const at = wv.birthTile(w, .{
        .r = .{ .target = hosts_table[0].spec.target, .label = "", .session = "0" },
        .from = 0,
        .place = .beside_focus,
        .creates = false,
        .born_from = null,
        .host = 0,
        .borrowed = true,
    }).?;
    // No pump was spawned, so nothing will ever clear the liveness a born
    // tile carries and `endPumps` would wait on it forever.
    tiles[at].alive.store(false, .release);
    wall_layout.persist(w);
    const first = try std.fs.cwd().readFileAlloc(alloc, path, 4096);
    try std.testing.expect(std.mem.indexOf(u8, first, "leaf 0 --sock /a#0") != null);

    wv.vanishTile(w.liveTiles(), w.livePresent(), &shared, at, null);
    wall_layout.persist(w);
    const second = try std.fs.cwd().readFileAlloc(alloc, path, 4096);
    try std.testing.expect(std.mem.indexOf(u8, second, "#0") == null);
}

test "persist: a save that fails says so on the notice line, where the wall says everything else" {
    var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer arena.deinit();
    const alloc = arena.allocator();
    var tmp = try TmpDir.make();
    defer tmp.cleanup();
    var path_buf: [std.fs.max_path_bytes]u8 = undefined;
    // A path whose parent is a FILE. `hosts.saveBytes` makes missing
    // directories, so a merely absent one would be created and the save
    // would succeed; this one fails at the write, past every check
    // `persist` itself makes.
    const blocker = try std.fmt.bufPrint(&path_buf, "{s}/blocker", .{tmp.path()});
    (try std.fs.cwd().createFile(blocker, .{})).close();
    var path_buf2: [std.fs.max_path_bytes]u8 = undefined;
    const path = try std.fmt.bufPrint(&path_buf2, "{s}/blocker/layout", .{tmp.path()});

    var shared: Shared = undefined;
    fixture.stoppedWall(alloc, &shared);
    shared.size = .{ .cols = 120, .rows = 40 };
    var tiles: [wv.max_tiles]Tile = undefined;
    var present = [_]bool{false} ** wv.max_tiles;
    var live: usize = 0;
    defer fixture.endPumps(tiles[0..live]);
    var hosts_table = [_]Host{fixture.testHost(&shared, "--sock /a", "/a")};
    const w = fixture.wallOf(alloc, &tiles, &present, &live, &shared, &hosts_table);
    shared.layout_path = path;
    const at = wv.birthTile(w, .{
        .r = .{ .target = hosts_table[0].spec.target, .label = "", .session = "0" },
        .from = 0,
        .place = .beside_focus,
        .creates = false,
        .born_from = null,
        .host = 0,
        .borrowed = true,
    }).?;
    tiles[at].alive.store(false, .release);

    wall_layout.persist(w);
    // Not on stderr and not `std.debug.print`: by the time any save runs the
    // wall owns the alternate screen, so the only place a sentence can go is
    // the slot every other refusal goes into.
    var buf: [96]u8 = undefined;
    const said = wv.takeNotice(&shared, &buf);
    if (!std.mem.startsWith(u8, said, "[layout not saved: ")) {
        std.debug.print("notice was: {s}\n", .{said});
        return error.FailedSaveSaidNothing;
    }
}

test "seed: a plan that leaves leaves behind counts them apart - the shell's own stripe and the panes that would not fit" {
    const alloc = std.testing.allocator;
    var shared: Shared = undefined;
    seedShared(alloc, &shared);
    defer shared.tree.deinit();
    // A tty, as every restoring wall is, so a label row counts toward each
    // pane's floor: seven rows hold two panes of three rows and not three.
    shared.is_tty = true;
    shared.size = .{ .cols = 100, .rows = 7 };
    var table = [_]Host{
        fixture.testHost(&shared, "--sock /tmp/h0.sock", "/tmp/h0.sock"),
        fixture.testHost(&shared, "--sock /tmp/h1.sock", "/tmp/h1.sock"),
    };
    // Four panes across TWO hosts, one of them the session this shell is
    // standing in: the two reasons a leaf goes unseated happen at once, and
    // a fixture with only one of them cannot see them counted apart.
    table[0].self_name = "s";
    const file = "mux-layout 1\nstacked 0\n" ++
        " leaf 1 --sock /tmp/h0.sock#s\n leaf 1 --sock /tmp/h0.sock#a\n" ++
        " leaf 1 --sock /tmp/h1.sock#b\n leaf 1 --sock /tmp/h1.sock#c\n";
    var res = wall_layout.seedLayout(alloc, &table, &shared, file, null);
    defer if (res == .plan) res.plan.deinit(alloc);

    try std.testing.expect(res == .plan);
    try std.testing.expectEqual(@as(usize, 2), res.plan.panes.len);
    // What `run` reads: nonzero `dropped` is a wall this terminal cannot
    // show whole, so the run stops saving rather than write the file back
    // without the leaves it left out. `dropped_self` is the half that no
    // terminal size would have helped, and it gets its own sentence.
    try std.testing.expectEqual(@as(usize, 2), res.plan.dropped);
    try std.testing.expectEqual(@as(usize, 1), res.plan.dropped_self);
}