par-term 0.48.0

Cross-platform GPU-accelerated terminal emulator with inline graphics support (Sixel, iTerm2, Kitty)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
//! Attached-tab (par-mux) tests for the UX.md P3 pane features: zoom
//! (A1, criterion 1), and keyboard focus / resize / tab switch reaching
//! the daemon so a SECOND client sees them (M7, criterion 4).
//!
//! The daemon's `list-panes` replies ids only (no active flag), so the
//! second client's evidence is its own notification stream:
//! `%window-pane-changed` for focus and window selection, `%layout-change`
//! for resizes and zoom — plus `pane-info` sizes read from the daemon.

use super::mux::MuxAttachPending;
use super::mux::tests::{manners_state, socket_path, spawn_daemon};
use super::mux_test_seams::{
    DAEMON_DEADLINE, SettledSend, attach_until_installed, quiesce, wait_until,
};
use crate::app::window_state::WindowState;
use crate::pane::NavigationDirection;
use par_term_emu_core_rust::mux::MuxClient;
use par_term_emu_core_rust::tmux_control::TmuxNotification;
use std::time::Instant;

fn send(ws: &WindowState, cmd: &str) -> Vec<String> {
    ws.tmux_state
        .transport
        .as_ref()
        .expect("transport")
        .send_settled(cmd)
        .unwrap_or_else(|e| panic!("{cmd}: {e}"))
}

/// Drain until `done` holds (checked before each pump, so a condition
/// already true applies nothing new), then settle the send worker so the
/// next inline action does not fail fast behind the drain's queued work.
fn drain_until(ws: &mut WindowState, what: &str, done: impl Fn(&WindowState) -> bool) {
    wait_until(what, || {
        done(ws) || {
            ws.check_mux_notifications();
            done(ws)
        }
    });
    quiesce(ws);
}

/// An attached window state with `%0` split side by side into `%0 | %1`,
/// both mapped, plus a second raw client connected to the same daemon.
fn attached_split(tag: &str) -> (WindowState, MuxClient, std::path::PathBuf) {
    let path = socket_path(tag);
    spawn_daemon(&path);
    let mut ws = manners_state();
    attach_until_installed(
        &mut ws,
        || {
            let core_client = MuxClient::connect(&path).expect("connect");
            let (tx, rx) = std::sync::mpsc::channel();
            tx.send(Ok(core_client)).unwrap();
            drop(tx);
            MuxAttachPending {
                name: tag.to_string(),
                rx,
            }
        },
        &path,
    );
    // The created session's %window-add waits in the client channel; the
    // drain's window adoption maps %0. A manual handle_tmux_window_add(0)
    // here would add a second tab for @0 and make window counts wrong.
    drain_until(&mut ws, "%0 mapped", |ws| {
        ws.tmux_state.tmux_pane_owners.contains_key(&0)
    });
    assert_eq!(ws.tab_manager.tab_count(), 1, "one tab for @0");
    assert!(ws.split_pane_via_mux(true), "daemon-side split");
    assert!(
        !ws.tmux_state
            .mux_last_error
            .as_deref()
            .is_some_and(|e| e.contains("split failed")),
        "the split reached the daemon: {:?}",
        ws.tmux_state.mux_last_error
    );
    drain_until(&mut ws, "split mapped", |ws| {
        ws.tmux_state.tmux_pane_owners.contains_key(&1)
            && ws
                .tab_manager
                .active_tab()
                .and_then(|t| t.pane_manager())
                .is_some_and(|pm| pm.pane_count() == 2)
    });
    // Lay the mirror out so directional lookups have real bounds.
    if let Some(pm) = ws
        .tab_manager
        .active_tab_mut()
        .and_then(|t| t.pane_manager_mut())
    {
        pm.set_bounds(crate::pane::PaneBounds::new(0.0, 0.0, 800.0, 480.0));
    }
    let mut second = MuxClient::connect(&path).expect("second client");
    // The daemon registers a connection for broadcasts on its first command.
    second
        .send("list-windows")
        .expect("second client registers");
    // Drop whatever the second client saw while connecting.
    while second.notifications().try_recv().is_ok() {}
    (ws, second, path)
}

/// Wait until the second client receives a notification matching `want`.
fn second_sees(second: &MuxClient, what: &str, want: impl Fn(&TmuxNotification) -> bool) {
    let deadline = Instant::now() + DAEMON_DEADLINE;
    loop {
        let left = deadline.saturating_duration_since(Instant::now());
        assert!(!left.is_zero(), "the second client never saw {what}");
        match second.notifications().recv_timeout(left) {
            Ok(n) if want(&n) => return,
            Ok(_) => {}
            Err(e) => panic!("the second client never saw {what}: {e}"),
        }
    }
}

fn focused_daemon_pane(ws: &WindowState) -> Option<u64> {
    ws.focused_mux_pane_from_native()
}

fn zoomed_native(ws: &WindowState) -> Option<crate::pane::PaneId> {
    ws.tab_manager
        .active_tab()
        .and_then(|t| t.pane_manager())
        .and_then(|pm| pm.zoomed_pane_id())
}

/// Criterion 1 (attached): zoom goes daemon-side, the mirror shows the
/// zoomed pane alone with the tab badge, the zoomed mirror terminal takes
/// the full window grid, and a keyboard focus move unzooms (the daemon
/// clears the zoom on `select-pane`). A split also unzooms.
#[test]
fn attached_zoom_toggles_shows_its_badge_and_a_focus_move_or_split_unzooms() {
    let (mut ws, second, path) = attached_split("p3-zoom");
    let focused = focused_daemon_pane(&ws).expect("a mux pane is focused");

    ws.toggle_pane_zoom();
    drain_until(&mut ws, "zoom mirrored", |ws| zoomed_native(ws).is_some());
    second_sees(&second, "the zoomed layout", |n| {
        matches!(n, TmuxNotification::LayoutChange { window_raw_flags, .. }
            if window_raw_flags.contains('Z'))
    });
    let tab = ws.tab_manager.active_tab().expect("tab");
    assert_eq!(
        tab.pane_mode_badge(),
        Some(crate::tab::pane_badges::ZOOM_BADGE),
        "the tab shows the zoom badge"
    );
    let pm = tab.pane_manager().expect("pm");
    assert_eq!(pm.visible_panes().len(), 1, "only the zoomed pane is drawn");
    assert_eq!(
        ws.tmux_state
            .tmux_pane_owner(focused)
            .map(|(_, native)| native),
        zoomed_native(&ws),
        "the focused pane is the zoomed one"
    );
    let info = send(&ws, &format!("pane-info -t %{focused}")).join(" ");
    let zoomed_mirror = pm
        .get_pane(zoomed_native(&ws).unwrap())
        .and_then(|p| p.terminal.try_read().ok())
        .map(|t| t.dimensions())
        .expect("mirror dims");
    assert!(
        info.contains(&format!("{}x{}", zoomed_mirror.0, zoomed_mirror.1)),
        "the zoomed mirror has the daemon PTY's full-window size {zoomed_mirror:?}: {info}"
    );

    // Keyboard focus move → select-pane → the DAEMON unzooms (the second
    // client sees an unflagged layout and the zoomed pane's PTY is back to
    // its tree size) → the mirror follows.
    let zoomed_cols = daemon_size(&ws, focused).0;
    ws.navigate_pane(NavigationDirection::Left);
    ws.navigate_pane(NavigationDirection::Right);
    second_sees(&second, "the unzoomed layout", |n| {
        matches!(n, TmuxNotification::LayoutChange { window_raw_flags, .. }
            if !window_raw_flags.contains('Z'))
    });
    assert!(
        daemon_size(&ws, focused).0 < zoomed_cols,
        "the daemon unzoomed: the pane is back to its tree width"
    );
    drain_until(&mut ws, "unzoom on focus move", |ws| {
        zoomed_native(ws).is_none()
    });
    assert_eq!(ws.tab_manager.active_tab().unwrap().pane_mode_badge(), None);

    // Zoom again, then split: the daemon clears the zoom for the new pane.
    ws.toggle_pane_zoom();
    drain_until(&mut ws, "zoom again", |ws| zoomed_native(ws).is_some());
    assert!(ws.split_pane_via_mux(false), "split while zoomed");
    drain_until(&mut ws, "unzoom on split", |ws| {
        zoomed_native(ws).is_none()
            && ws
                .tab_manager
                .active_tab()
                .and_then(|t| t.pane_manager())
                .is_some_and(|pm| pm.pane_count() == 3)
    });

    let _ = std::fs::remove_file(&path);
}

/// Criterion 4 (focus): keyboard focus moves — directional, next/prev,
/// last pane — reach the daemon as `select-pane`, which a second client
/// sees as `%window-pane-changed` naming the newly focused pane.
#[test]
fn keyboard_focus_moves_are_visible_to_a_second_client() {
    let (mut ws, second, path) = attached_split("p3-focus");
    let start = focused_daemon_pane(&ws).expect("focused");

    ws.focus_pane_cycle(true);
    let after_next = focused_daemon_pane(&ws).expect("focused");
    assert_ne!(after_next, start, "next pane moved focus");
    second_sees(&second, "select-pane after next_pane", |n| {
        matches!(n, TmuxNotification::WindowPaneChanged { pane_id, .. }
            if *pane_id == format!("%{after_next}"))
    });

    ws.focus_last_pane();
    assert_eq!(
        focused_daemon_pane(&ws),
        Some(start),
        "last pane toggles back"
    );
    second_sees(&second, "select-pane after last_pane", |n| {
        matches!(n, TmuxNotification::WindowPaneChanged { pane_id, .. }
            if *pane_id == format!("%{start}"))
    });

    let dir = if ws
        .tab_manager
        .active_tab()
        .and_then(|t| t.pane_manager())
        .and_then(|pm| pm.neighbor_in_direction(pm.focused_pane_id()?, NavigationDirection::Left))
        .is_some()
    {
        NavigationDirection::Left
    } else {
        NavigationDirection::Right
    };
    ws.navigate_pane(dir);
    let after_nav = focused_daemon_pane(&ws).expect("focused");
    assert_ne!(after_nav, start);
    second_sees(&second, "select-pane after directional focus", |n| {
        matches!(n, TmuxNotification::WindowPaneChanged { pane_id, .. }
            if *pane_id == format!("%{after_nav}"))
    });

    let _ = std::fs::remove_file(&path);
}

/// The daemon pane's grid size, from `pane-info` (`%N @W COLSxROWS ...`).
fn daemon_size(ws: &WindowState, pane: u64) -> (u16, u16) {
    let info = send(ws, &format!("pane-info -t %{pane}")).join(" ");
    let size = info.split_whitespace().nth(2).expect("size field");
    let (c, r) = size.split_once('x').expect("COLSxROWS");
    (c.parse().expect("cols"), r.parse().expect("rows"))
}

/// Criterion 4 (resize): a keyboard arrow resize on an attached tab goes
/// daemon-side (`resize-pane -L/-R`), so the daemon pane's size changes in
/// the arrow's direction and a second client receives the `%layout-change`.
#[test]
fn keyboard_resize_is_visible_to_a_second_client() {
    let (mut ws, second, path) = attached_split("p3-resize");
    let focused = focused_daemon_pane(&ws).expect("focused");
    let left_is_focused = ws
        .tab_manager
        .active_tab()
        .and_then(|t| t.pane_manager())
        .and_then(|pm| pm.neighbor_in_direction(pm.focused_pane_id()?, NavigationDirection::Right))
        .is_some();
    // A same-panes layout push makes the mirror adopt the daemon grid (the
    // headless test has no renderer to size it), so pixel bounds and
    // cells agree before the resize is computed.
    send(&ws, "refresh-client -t %0 -C 80x24");
    drain_until(&mut ws, "mirror adopts the daemon grid", |ws| {
        ws.tmux_state
            .tmux_pane_owner(focused)
            .is_some_and(|(tab, native)| {
                ws.tab_manager
                    .get_tab(tab)
                    .and_then(|t| t.pane_manager())
                    .and_then(|pm| pm.get_pane(native))
                    .and_then(|p| p.terminal.try_read().ok().map(|t| t.dimensions().0))
                    == Some(daemon_size(ws, focused).0 as usize)
            })
    });
    while second.notifications().try_recv().is_ok() {}
    let (cols_before, rows_before) = daemon_size(&ws, focused);

    // Right on the left pane grows it; Right on the right pane moves its
    // left divider right, shrinking it — either way the divider moves right.
    ws.resize_pane(NavigationDirection::Right);
    second_sees(&second, "the resize layout", |n| {
        matches!(n, TmuxNotification::LayoutChange { .. })
    });
    let (cols_after, rows_after) = daemon_size(&ws, focused);
    assert_eq!(rows_after, rows_before, "a Right resize keeps the height");
    if left_is_focused {
        assert!(cols_after > cols_before, "{cols_before} -> {cols_after}");
    } else {
        assert!(cols_after < cols_before, "{cols_before} -> {cols_after}");
    }
    // The mirror adopts the daemon geometry from the layout push.
    drain_until(&mut ws, "mirror resized", |ws| {
        ws.tmux_state
            .tmux_pane_owner(focused)
            .and_then(|(tab, native)| {
                ws.tab_manager
                    .get_tab(tab)?
                    .pane_manager()?
                    .get_pane(native)?
                    .terminal
                    .try_read()
                    .ok()
                    .map(|t| t.dimensions().0 as u16 == cols_after)
            })
            .unwrap_or(false)
    });

    let _ = std::fs::remove_file(&path);
}

/// Resize-while-zoomed keeps the zoom (card 01a0f0e7): the arrow resize
/// edits the underlying split and the daemon zoom survives. The core pin
/// unzooms on every accepted non-Z layout mutation (ARC-090 remedy in the
/// core's `mutate_layout`), so the resize sync re-asserts `resize-pane -Z`
/// for the zoomed pane right after its own resize. Without that re-assert
/// the unflagged `%layout-change` pulls the daemon and the mirror back to
/// unzoomed — this also fails on the local-only fix (ad01babb) alone.
#[test]
fn keyboard_resize_while_zoomed_keeps_the_zoom() {
    let (mut ws, second, path) = attached_split("p3-zoom-resize");
    let focused = focused_daemon_pane(&ws).expect("focused");
    let left_is_focused = ws
        .tab_manager
        .active_tab()
        .and_then(|t| t.pane_manager())
        .and_then(|pm| pm.neighbor_in_direction(pm.focused_pane_id()?, NavigationDirection::Right))
        .is_some();
    // A same-panes layout push makes the mirror adopt the daemon grid (the
    // headless test has no renderer to size it), so pixel bounds and
    // cells agree before the resize is computed.
    send(&ws, "refresh-client -t %0 -C 80x24");
    drain_until(&mut ws, "mirror adopts the daemon grid", |ws| {
        ws.tmux_state
            .tmux_pane_owner(focused)
            .is_some_and(|(tab, native)| {
                ws.tab_manager
                    .get_tab(tab)
                    .and_then(|t| t.pane_manager())
                    .and_then(|pm| pm.get_pane(native))
                    .and_then(|p| p.terminal.try_read().ok().map(|t| t.dimensions().0))
                    == Some(daemon_size(ws, focused).0 as usize)
            })
    });
    let (cols_unzoomed, rows_unzoomed) = daemon_size(&ws, focused);

    ws.toggle_pane_zoom();
    drain_until(&mut ws, "zoom mirrored", |ws| zoomed_native(ws).is_some());
    second_sees(&second, "the zoomed layout", |n| {
        matches!(n, TmuxNotification::LayoutChange { window_raw_flags, .. }
            if window_raw_flags.contains('Z'))
    });
    while second.notifications().try_recv().is_ok() {}

    ws.resize_pane(NavigationDirection::Right);

    // The daemon unzoomed on the resize (unflagged `%layout-change`), so a
    // flagged broadcast arriving AFTER one proves the re-assert re-zoomed.
    let deadline = Instant::now() + DAEMON_DEADLINE;
    let mut saw_unzoomed = false;
    loop {
        let left = deadline.saturating_duration_since(Instant::now());
        assert!(
            !left.is_zero(),
            "the daemon never echoed the resize and re-zoomed"
        );
        match second.notifications().recv_timeout(left) {
            Ok(TmuxNotification::LayoutChange {
                window_raw_flags, ..
            }) => {
                if window_raw_flags.contains('Z') {
                    assert!(saw_unzoomed, "the re-zoom arrived before the resize echo");
                    break;
                }
                saw_unzoomed = true;
            }
            Ok(_) => {}
            Err(e) => panic!("the daemon never re-zoomed after the resize: {e}"),
        }
    }
    drain_until(&mut ws, "the mirror keeps the zoom", |ws| {
        zoomed_native(ws).is_some()
    });
    assert_eq!(
        ws.tab_manager.active_tab().unwrap().pane_mode_badge(),
        Some(crate::tab::pane_badges::ZOOM_BADGE),
        "the zoom survives the resize"
    );

    // The hidden tree holds the edit: focusing away unzooms (the daemon
    // clears a zoom on select-pane), and the zoomed pane's cell is back to
    // tree size — wider or narrower per the arrow, proving the divider
    // really moved beneath the zoom.
    let nav = if left_is_focused {
        NavigationDirection::Right
    } else {
        NavigationDirection::Left
    };
    ws.navigate_pane(nav);
    drain_until(&mut ws, "unzoomed after focus move", |ws| {
        zoomed_native(ws).is_none()
    });
    let (cols_after, rows_after) = daemon_size(&ws, focused);
    assert_eq!(
        rows_after, rows_unzoomed,
        "a horizontal resize keeps the height"
    );
    if left_is_focused {
        assert!(
            cols_after > cols_unzoomed,
            "the zoomed resize grew the pane: {cols_unzoomed} -> {cols_after}"
        );
    } else {
        assert!(
            cols_after < cols_unzoomed,
            "the zoomed resize shrank the pane: {cols_unzoomed} -> {cols_after}"
        );
    }

    let _ = std::fs::remove_file(&path);
}

/// Criterion 4 (tab switch): switching tabs from the keyboard on an
/// attached window sends `select-window`, which a second client sees as
/// `%window-pane-changed` for the newly selected window.
#[test]
fn keyboard_tab_switch_is_visible_to_a_second_client() {
    let (mut ws, second, path) = attached_split("p3-tabs");
    send(&ws, "new-window");
    drain_until(&mut ws, "second window mapped", |ws| {
        ws.tab_manager
            .tabs()
            .iter()
            .filter(|t| ws.mux_window_for_tab(t.id).is_some())
            .count()
            == 2
    });
    while second.notifications().try_recv().is_ok() {}

    for step in 0..2 {
        ws.next_tab();
        let window = ws
            .tab_manager
            .active_tab_id()
            .and_then(|id| ws.mux_window_for_tab(id))
            .expect("the active tab mirrors a daemon window");
        second_sees(&second, &format!("select-window step {step}"), |n| {
            matches!(n, TmuxNotification::WindowPaneChanged { window_id, .. }
                if *window_id == format!("@{window}"))
        });
    }

    let _ = std::fs::remove_file(&path);
}

/// Split left (A5) in an attached tab: `split-window -b` puts the new
/// daemon pane left of the focused one, and the mirror follows.
#[test]
fn attached_split_left_places_the_new_pane_before_the_focused_one() {
    let (mut ws, _second, path) = attached_split("p3-split-left");
    let focused = focused_daemon_pane(&ws).expect("focused");
    let before: std::collections::HashSet<u64> =
        ws.tmux_state.tmux_pane_owners.keys().copied().collect();
    ws.split_pane_before(crate::pane::SplitDirection::Vertical);
    drain_until(&mut ws, "split-left mapped", |ws| {
        ws.tmux_state.tmux_pane_owners.len() == before.len() + 1
    });
    let new = *ws
        .tmux_state
        .tmux_pane_owners
        .keys()
        .find(|p| !before.contains(p))
        .expect("new daemon pane");
    // Lay the mirror out and compare x positions.
    if let Some(pm) = ws
        .tab_manager
        .active_tab_mut()
        .and_then(|t| t.pane_manager_mut())
    {
        pm.set_bounds(crate::pane::PaneBounds::new(0.0, 0.0, 800.0, 480.0));
    }
    let x_of = |ws: &WindowState, daemon: u64| {
        let (tab, native) = ws.tmux_state.tmux_pane_owner(daemon).expect("mapped");
        ws.tab_manager
            .get_tab(tab)
            .and_then(|t| t.pane_manager())
            .and_then(|pm| pm.get_pane(native))
            .map(|p| p.bounds.x)
            .expect("mirror pane")
    };
    assert!(
        x_of(&ws, new) < x_of(&ws, focused),
        "the new pane sits left of the pane that was focused"
    );
    let _ = std::fs::remove_file(&path);
}

/// Equalize (A3) in an attached tab: after a resize skews the split, the
/// mirror computes equal sizes and sends them as absolute `resize-pane`;
/// the daemon's panes come back within a cell of each other and a second
/// client sees the `%layout-change`.
#[test]
fn attached_equalize_evens_the_daemon_panes() {
    let (mut ws, second, path) = attached_split("p3b-equalize");
    send(&ws, "refresh-client -t %0 -C 80x24");
    drain_until(&mut ws, "mirror adopts the daemon grid", |ws| {
        ws.tmux_state
            .tmux_pane_owner(0)
            .is_some_and(|(tab, native)| {
                ws.tab_manager
                    .get_tab(tab)
                    .and_then(|t| t.pane_manager())
                    .and_then(|pm| pm.get_pane(native))
                    .and_then(|p| p.terminal.try_read().ok().map(|t| t.dimensions().0))
                    == Some(daemon_size(ws, 0).0 as usize)
            })
    });
    send(&ws, "resize-pane -t %0 -x 60");
    drain_until(&mut ws, "skewed layout mirrored", |ws| {
        daemon_size(ws, 0).0 >= 55
    });
    while second.notifications().try_recv().is_ok() {}

    ws.equalize_panes();
    second_sees(&second, "the equalize layout", |n| {
        matches!(n, TmuxNotification::LayoutChange { .. })
    });
    wait_until("the daemon panes even out", || {
        ws.check_mux_notifications();
        daemon_size(&ws, 0).0.abs_diff(daemon_size(&ws, 1).0) <= 1
    });
    let _ = std::fs::remove_file(&path);
}

/// Layout presets (A4) are refused in an attached tab with a toast, and
/// the daemon layout is untouched: core 0.57 has no `select-layout`, and
/// a structural preset needs panes moved daemon-side (documented bound).
#[test]
fn attached_layout_presets_are_refused_and_leave_the_daemon_layout() {
    let (mut ws, _second, path) = attached_split("p3b-preset");
    let before = (daemon_size(&ws, 0), daemon_size(&ws, 1));
    for preset in crate::pane::LayoutPreset::ALL {
        ws.overlay_state.toasts.clear();
        ws.apply_layout_preset(preset);
        let toast = ws.last_toast_text().map(str::to_string).unwrap_or_default();
        assert!(
            toast.contains("not available in par-mux tabs"),
            "{}: {toast:?}",
            preset.name()
        );
    }
    // Anything a preset queued has reached the daemon once the worker is
    // idle, so the sizes below are final.
    quiesce(&mut ws);
    assert_eq!((daemon_size(&ws, 0), daemon_size(&ws, 1)), before);
    let _ = std::fs::remove_file(&path);
}