From abc9ea2071b2d19894d4060d18c9877e4a20dadd Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sun, 23 Aug 2026 12:53:15 +0800 Subject: [PATCH] test(machine): pin the active mark and the pane record a close leaves behind MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two rules of the authority's `pane_close` were unverified, and both are the server-side twin of a gap the mirror had. `active_tab` is an id, so one naming a tab that has just been emptied points at nothing — and since the move never went out as a delta, every client keeps its own stale answer too. Deleting the healing passed the suite. `collect_orphan_panes` reads `m.panes`, so a record left behind after its tab closed answers "orphan" on every later call, for ever, because nothing else removes it. It persists as well: `machine.json` gains a row per closed pane, and `pane ls --all` reads them back as panes no workspace holds. Deleting the prune passed the suite too. Both are held now, at the last pane of a tab and at a plain split close. Checked by re-running the two mutations that had survived. --- crates/tty7-core/src/core/machine.rs | 65 ++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/crates/tty7-core/src/core/machine.rs b/crates/tty7-core/src/core/machine.rs index 437a9895..b37d0fc1 100644 --- a/crates/tty7-core/src/core/machine.rs +++ b/crates/tty7-core/src/core/machine.rs @@ -2377,6 +2377,71 @@ mod tests { ); } + /// Closing a tab out from under the active mark moves the mark, and says + /// so. + /// + /// `active_tab` is an id, so one naming a tab that has just been removed + /// points at nothing — and because the change never went out as a delta, + /// every client keeps its own stale answer too. The healing was there; the + /// test was not, and removing it passed the suite. + #[test] + fn emptying_a_tab_moves_the_active_mark_and_announces_it() { + let (store, _dir, ws, first) = store_with_tab(); + let second = store.tab_create(ws, None, seed(9, "/b"), None, None).unwrap(); + store + .workspace_set_active_tab(ws, second.id, None) + .unwrap(); + assert_eq!(store.workspace(ws).unwrap().active_tab, Some(second.id)); + + // Closing the second tab's only pane takes the tab with it. + let (_sub, heard) = recorded(&store); + store.pane_close(ws, 9, None).unwrap(); + + let after = store.workspace(ws).unwrap(); + assert_eq!(after.tabs.len(), 1, "the emptied tab is gone"); + assert_eq!( + after.active_tab, + Some(first.id), + "the mark moved to the tab that is left, not to one that is not" + ); + assert!( + heard.lock().unwrap().iter().any(|(_, d)| matches!( + d, + LayoutDelta::ActiveTabChanged { tab } if *tab == first.id + )), + "and the move went out, or every client keeps the dead id" + ); + } + + /// A pane nothing holds any more leaves the document with it. + /// + /// `collect_orphan_panes` reads `m.panes`, so a record left behind after + /// its tab closed answers "orphan" on every later call — for ever, since + /// nothing else removes it. It also persists: `machine.json` accumulates a + /// row per closed pane, and `pane ls --all` reads them back as panes that + /// no workspace holds. + #[test] + fn a_closed_pane_leaves_no_record_behind() { + let (store, _dir, ws, _tab) = store_with_tab(); + store + .pane_split(ws, 1, Axis::Horizontal, 0.5, seed(2, "/b"), false, None) + .unwrap(); + assert_eq!(store.machine().panes.len(), 2); + + let dropped = store.pane_close(ws, 2, None).unwrap(); + assert_eq!(dropped, vec![2], "the caller is told to hang it up"); + assert_eq!( + store.machine().panes.iter().map(|p| p.id).collect::>(), + vec![1], + "and the record goes with it, or it is an orphan on every later look" + ); + + // The last one too, when the tab goes with it. + let dropped = store.pane_close(ws, 1, None).unwrap(); + assert_eq!(dropped, vec![1]); + assert!(store.machine().panes.is_empty()); + } + #[test] fn splitting_and_closing_panes_reshapes_the_tree() { let (store, _dir, ws, tab) = store_with_tab();