//! Terminal copy-mode acceptance (Stage 2 of //! `docs/terminal-config-and-copy-mode-framing.md`, criteria 13-21). //! //! **Deliberately NOT `#[cfg(feature = "crdt")]`.** CI never enables that //! feature, so a gated suite is written and then never run — 264 tests are //! dark workspace-wide for exactly that reason. Criterion 16, the //! round-trip gate Q#TC6a's entire safety argument rests on, needs no CRDT //! and must be caught by the default configuration. use std::thread; use std::time::{Duration, Instant}; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use mlua::Value; use pmacs::cell::{CellSize, Glyph}; use pmacs::editor::EditorState; use pmacs::protocol::FrontendId; use pmacs::terminal::TerminalViewKey; const SNAPSHOT_NAME: &str = "*terminal-copy: terminal:sh*"; fn exec(state: &EditorState, src: &str) { state .lua_host .lua() .load(src) .exec() .unwrap_or_else(|e| panic!("lua failed: {src}\n{e}")); } fn eval(state: &EditorState, src: &str) -> T { state .lua_host .lua() .load(src) .eval() .unwrap_or_else(|e| panic!("lua eval failed: {src}\n{e}")) } fn eval_err(state: &EditorState, src: &str) -> String { let result: mlua::Result = state.lua_host.lua().load(src).eval(); match result { Ok(_) => panic!("expected an error from: {src}"), Err(e) => e.to_string(), } } fn press(state: &mut EditorState, code: KeyCode, mods: KeyModifiers) { state.dispatch_key(FrontendId::LOCAL, KeyEvent::new(code, mods)); } /// The live terminal screen's text, used only to wait for the child. fn screen_text(state: &EditorState, buffer: pmacs::buffer::BufferId) -> String { let manager = state.terminal_manager.borrow(); let Some(snapshot) = manager.snapshot(buffer) else { return String::new(); }; let mut text = String::new(); for cell in &snapshot.cells { match &cell.glyph { Glyph::Char(c) => text.push(*c), Glyph::Cluster(b) => text.push_str(&String::from_utf8_lossy(b)), Glyph::Continuation => {} } } text } fn tick_until(state: &mut EditorState, needle: &str, buffer: pmacs::buffer::BufferId) -> bool { let deadline = Instant::now() + Duration::from_secs(5); loop { state.tick_processes(); if screen_text(state, buffer).contains(needle) { return true; } if Instant::now() >= deadline { return false; } thread::sleep(Duration::from_millis(20)); } } fn terminal_buffers(state: &EditorState) -> Vec { let manager = state.terminal_manager.borrow(); state .core .borrow() .registry .borrow() .ids() .iter() .copied() .filter(|id| manager.is_terminal(*id)) .collect() } /// A child that overflows the 24-row screen and then goes quiet, so its /// early lines exist ONLY in scrollback — which is what makes criterion /// 15's "content only in scrollback" claim meaningful. const FILL_PROFILE: &str = r#" pmacs.terminal.profiles.fill = { command = "/bin/sh", args = { "-c", "printf 'NEEDLE-IN-SCROLLBACK\r\n'; i=1; while [ $i -le 200 ]; do printf 'LINE%03d\r\n' $i; i=$((i+1)); done; printf 'DONE\r\n'; exec cat" }, } "#; /// Open the fill terminal, wait for the child to finish, and return its id. fn open_fill_terminal(state: &mut EditorState) -> pmacs::buffer::BufferId { exec(state, FILL_PROFILE); let before = terminal_buffers(state); exec( state, r#"TERM_BUF = pmacs.terminal.open { profile = "fill" }"#, ); let fresh: Vec<_> = terminal_buffers(state) .into_iter() .filter(|id| !before.contains(id)) .collect(); assert_eq!(fresh.len(), 1, "exactly one terminal must have opened"); let buffer = fresh[0]; assert!(tick_until(state, "DONE", buffer), "the child must finish"); buffer } fn viewport() -> CellSize { CellSize::new(10, 40) } /// Give LOCAL a window on the terminal and register/claim its view, which /// is what makes `dispatch_key`'s terminal transport arm reachable. /// Returns the view key, so assertions can read the *projected* view /// rather than the context-free live screen. fn focus_terminal(state: &EditorState, buffer: pmacs::buffer::BufferId) -> TerminalViewKey { state.core.borrow_mut().switch_active_buffer(buffer).ok(); let window = state.core.borrow().active_window_id(); let key = TerminalViewKey::new(FrontendId::LOCAL, window, buffer); let mut manager = state.terminal_manager.borrow_mut(); manager.register_view(key); manager.claim_controller(key); let _ = manager.snapshot_for_view(key, viewport()); key } /// Make the child produce NEW output, so a refresh has something to find. /// /// The child is `exec cat`, so typing into the focused terminal echoes /// back. Without this, "refresh" tests compare a quiet terminal against /// itself and pass with the render replaced by a no-op — the defect review /// round 1 found in acceptance 18 and 19. fn emit_into_child(state: &mut EditorState, terminal: pmacs::buffer::BufferId, marker: &str) { focus_terminal(state, terminal); for ch in marker.chars() { press(state, KeyCode::Char(ch), KeyModifiers::NONE); } assert!( tick_until(state, marker, terminal), "the child must echo {marker:?} back onto the live screen" ); } /// What the registered VIEW currently projects — which, unlike /// `manager.snapshot(buffer)`, depends on where the view is anchored. fn view_text(state: &EditorState, key: TerminalViewKey) -> String { let mut manager = state.terminal_manager.borrow_mut(); let Some(snapshot) = manager.snapshot_for_view(key, viewport()) else { return String::new(); }; let mut text = String::new(); for cell in &snapshot.cells { match &cell.glyph { Glyph::Char(c) => text.push(*c), Glyph::Cluster(b) => text.push_str(&String::from_utf8_lossy(b)), Glyph::Continuation => {} } } text } fn view_at_bottom(state: &EditorState, key: TerminalViewKey) -> bool { state .terminal_manager .borrow_mut() .snapshot_for_view(key, viewport()) .is_some_and(|snapshot| snapshot.at_bottom) } fn buffer_text_by_name(state: &EditorState, name: &str) -> Option { eval( state, &format!( r" for _, id in ipairs(pmacs.buffer.list()) do local ok, d = pcall(pmacs.describe.buffer, id) if ok and d and d.name == {name:?} then return id:slice(0, id:len()) end end return nil " ), ) } fn active_buffer_name(state: &EditorState) -> String { eval( state, r"local b = pmacs.window.buffer(); return (pmacs.describe.buffer(b)).name", ) } fn buffer_count(state: &EditorState) -> usize { state.core.borrow().registry.borrow().ids().len() } /// Acceptance 13: the snapshot's text is exactly the whole retained range /// as the existing copy path serializes it. /// /// Compared against `_copy_retained` rather than a literal, so this cannot /// pass by both sides drifting the same way; the exact-bytes fidelity /// claims (criterion 14) are pinned at the unit level in /// `src/terminal/view.rs`, against the same projection fixtures that pin /// `copy_selection_bytes` itself. #[test] fn acc13_snapshot_is_the_whole_retained_range_through_the_shared_serializer() { let mut state = EditorState::new_with_roots(&crate::iso::roots()); let terminal = open_fill_terminal(&mut state); focus_terminal(&state, terminal); exec(&state, "SNAP = pmacs.terminal.copy_mode(TERM_BUF)"); let snapshot_text = buffer_text_by_name(&state, SNAPSHOT_NAME).expect("snapshot buffer exists"); let serialized: String = eval( &state, r"return pmacs.terminal._copy_retained(TERM_BUF) or ''", ); assert_eq!( snapshot_text, serialized, "the snapshot must be byte-identical to the shared serializer's output" ); assert!( snapshot_text.contains("NEEDLE-IN-SCROLLBACK") && snapshot_text.contains("LINE200"), "the range must span scrollback AND the visible screen" ); state.process_supervisor.borrow_mut().shutdown(); } /// Acceptance 14 (end-to-end half): the snapshot really is a rope-backed /// document buffer and not a terminal, which is what makes every /// buffer-shaped consumer work and what removes the transport arm. #[test] fn acc14_the_snapshot_is_an_ordinary_non_terminal_buffer() { let mut state = EditorState::new_with_roots(&crate::iso::roots()); let terminal = open_fill_terminal(&mut state); focus_terminal(&state, terminal); exec(&state, "pmacs.terminal.copy_mode(TERM_BUF)"); let is_terminal: bool = eval( &state, r"local b = pmacs.window.buffer(); return pmacs.terminal.is_terminal(b)", ); assert!( !is_terminal, "the snapshot must NOT be a terminal — that is what structurally \ removes the transport arm rather than guarding it" ); assert_eq!(active_buffer_name(&state), SNAPSHOT_NAME); state.process_supervisor.borrow_mut().shutdown(); } /// Acceptance 15: isearch finds content that exists ONLY in scrollback, /// with no change to `src/search.rs` (B1). #[test] fn acc15_isearch_finds_content_only_in_scrollback() { let mut state = EditorState::new_with_roots(&crate::iso::roots()); let terminal = open_fill_terminal(&mut state); focus_terminal(&state, terminal); // The needle is off the visible screen: the live terminal cannot see it. assert!( !screen_text(&state, terminal).contains("NEEDLE-IN-SCROLLBACK"), "precondition: the needle must have scrolled off the live screen" ); exec(&state, "pmacs.terminal.copy_mode(TERM_BUF)"); state.core.borrow_mut().set_cursor_byte(0); // Drive real isearch: C-s then the needle. press(&mut state, KeyCode::Char('s'), KeyModifiers::CONTROL); for ch in "NEEDLE-IN-SCROLLBACK".chars() { press(&mut state, KeyCode::Char(ch), KeyModifiers::NONE); } let cursor = state.core.borrow().cursor(); press(&mut state, KeyCode::Enter, KeyModifiers::NONE); let text = buffer_text_by_name(&state, SNAPSHOT_NAME).expect("snapshot"); let expected = text .find("NEEDLE-IN-SCROLLBACK") .expect("the needle is in the snapshot") as u64; assert_eq!( cursor, expected, "isearch must land on the scrollback-only match; text was {:?}", &text[..text.len().min(80)] ); state.process_supervisor.borrow_mut().shutdown(); } /// Acceptance 16 — the load-bearing pin, and the reason this suite is /// ungated. `set_round_trip_input` is the ONLY thing standing between a /// replica frontend and unauthorized mutation **of its own mirror** /// (Q#TC6a), so its regression must be caught in the configuration CI /// actually compiles. /// /// Rope-level `read_only` does not substitute for it. Since review round 2 /// the daemon refuses such an op at `ensure_writable()` — but a refusal /// arrives after the frontend has already applied optimistically and /// painted the result. What that buys is divergence instead of silent /// agreement; what stops the mutation is this. #[test] fn acc16_dispatch_idle_is_false_while_the_snapshot_is_focused() { let mut state = EditorState::new_with_roots(&crate::iso::roots()); let terminal = open_fill_terminal(&mut state); focus_terminal(&state, terminal); exec(&state, "pmacs.terminal.copy_mode(TERM_BUF)"); assert!( !state.dispatch_idle(), "a focused snapshot must round-trip keys, so no replica applies \ optimistically and none emits a CRDT op" ); // ...and it is the SNAPSHOT that does it, not merely "some terminal // buffer is around": switching to an ordinary buffer restores idle. exec( &state, r#"pmacs.window.switch_buffer(pmacs.buffer.create("*plain*"))"#, ); assert!(state.dispatch_idle(), "an ordinary buffer is idle again"); state.process_supervisor.borrow_mut().shutdown(); } /// Acceptance 16 (the other half): the intercept rejects ordinary edits, /// and the buffer is genuinely `read_only` at the rope boundary, so the /// protection does not depend on which key or command was used. #[test] fn acc16b_the_snapshot_is_immutable_at_the_rope_not_merely_intercepted() { let mut state = EditorState::new_with_roots(&crate::iso::roots()); let terminal = open_fill_terminal(&mut state); focus_terminal(&state, terminal); exec(&state, "pmacs.terminal.copy_mode(TERM_BUF)"); let before = buffer_text_by_name(&state, SNAPSHOT_NAME).expect("snapshot"); press(&mut state, KeyCode::Char('z'), KeyModifiers::NONE); let after = buffer_text_by_name(&state, SNAPSHOT_NAME).expect("snapshot"); assert_eq!(before, after, "the read-only intercept rejects self-insert"); let core = state.core.borrow(); let registry = core.registry.borrow(); let ids = registry.ids(); let snapshot = ids .iter() .copied() .find(|id| { registry .get(*id) .is_ok_and(|buf| buf.name() == SNAPSHOT_NAME) }) .expect("snapshot buffer id"); assert!( registry .get(snapshot) .expect("snapshot buffer") .is_read_only(), "an intercept guards the dispatch path only; `Buffer::undo` reaches \ the rope through `ensure_writable` without consulting it, so the \ snapshot must be read-only at the rope" ); drop(registry); drop(core); state.process_supervisor.borrow_mut().shutdown(); } /// Acceptance 16c (review round 2, P1): **undo cannot empty the snapshot**, /// through the chord *or* through the command. /// /// The chord half alone would be a false pass. `M-x buffer.undo` and the /// menu reach `Buffer::undo` without passing through any buffer-local /// keymap, so rebinding `C-/` to a no-op — the existing `*compilation*` /// idiom, which documents that "command/menu undo stays dispatchable" — /// leaves the buffer emptiable. Only rope-level `read_only` closes both. #[test] fn acc16c_undo_cannot_empty_the_snapshot_by_chord_or_by_command() { let mut state = EditorState::new_with_roots(&crate::iso::roots()); let terminal = open_fill_terminal(&mut state); focus_terminal(&state, terminal); exec(&state, "pmacs.terminal.copy_mode(TERM_BUF)"); let rendered = buffer_text_by_name(&state, SNAPSHOT_NAME).expect("snapshot"); assert!( rendered.contains("LINE200"), "precondition: the snapshot has content to lose" ); // The command path — reachable regardless of any buffer-local binding. let _: Value = state .lua_host .lua() .load(r"return pcall(pmacs.command.invoke_interactive, 'buffer.undo')") .eval() .expect("invoke_interactive is callable"); assert_eq!( buffer_text_by_name(&state, SNAPSHOT_NAME).as_deref(), Some(rendered.as_str()), "M-x buffer.undo must not empty the snapshot" ); // The chord path. press(&mut state, KeyCode::Char('/'), KeyModifiers::CONTROL); assert_eq!( buffer_text_by_name(&state, SNAPSHOT_NAME).as_deref(), Some(rendered.as_str()), "C-/ must not empty the snapshot" ); // Redo is the same door. let _: Value = state .lua_host .lua() .load(r"return pcall(pmacs.command.invoke_interactive, 'buffer.redo')") .eval() .expect("invoke_interactive is callable"); assert_eq!( buffer_text_by_name(&state, SNAPSHOT_NAME).as_deref(), Some(rendered.as_str()), "buffer.redo must not alter the snapshot either" ); // ...and the owner's own refresh still works, which is the whole // reason plain `read_only` was not enough on its own. emit_into_child(&mut state, terminal, "STILLREFRESHES"); exec(&state, "pmacs.terminal.copy_mode(TERM_BUF)"); assert!( buffer_text_by_name(&state, SNAPSHOT_NAME) .expect("snapshot") .contains("STILLREFRESHES"), "the owner-authorized write path must survive immutability" ); state.process_supervisor.borrow_mut().shutdown(); } /// Snapshot buffer id, by name, from the Rust side. #[cfg(feature = "crdt")] fn snapshot_buffer_id(state: &EditorState) -> pmacs::buffer::BufferId { let core = state.core.borrow(); let reg = core.registry.borrow(); reg.ids() .iter() .copied() .find(|id| reg.get(*id).is_ok_and(|b| b.name() == SNAPSHOT_NAME)) .expect("snapshot buffer exists") } /// Rendered cells of the active window (the `m4_acceptance` grid helper; /// cross-crate test code can't import it). fn render_active_window_to_grid( state: &mut EditorState, rows: u32, cols: u32, ) -> Vec { use pmacs::cell::{Cell, CellGrid}; use pmacs::view::{View, Viewport}; use pmacs::window::Rect; let mut core = state.core.borrow_mut(); let active = core.active_window_id(); let registry = core.registry.clone(); let win = core.windows.get_mut(&active).expect("active window"); let rect = Rect::new(0, 0, rows, cols); let mut backing = vec![Cell::default(); (rows * cols) as usize]; let reg = registry.borrow(); let buf = reg.get(win.buffer_id).expect("buffer in registry"); let viewport = Viewport { buffer_start: 0, buffer_end: buf.len(), cell_origin: rect.origin, cell_size: CellSize::new(rows, cols), gutter_w: 0, folds: None, }; let mut grid = CellGrid { cells: &mut backing, stride: cols, size: CellSize::new(rows, cols), }; win.text_view.render(buf, viewport, &mut grid); backing } fn grid_row(cells: &[pmacs::cell::Cell], row: u32, cols: u32) -> String { (0..cols) .map(|c| match cells[(row * cols + c) as usize].glyph { Glyph::Char(ch) => ch, _ => ' ', }) .collect::() .trim_end() .to_owned() } /// Review round 3, P1. A rope write is only half of an edit: the window /// showing the buffer holds a `TextView` line index that only `on_edit` /// maintains, so a write that reaches the rope without the notification /// leaves the two disagreeing. /// /// Pinned by PAINTING, because that is where the disagreement bites: with /// the fan-out dropped, the next render indexes the new rope with the old /// line offsets. A shrinking write is used deliberately — stale offsets /// then point past the buffer end, which is the reported crash rather than /// merely stale pixels. /// /// Driven through `pmacs.buffer.set_generated_contents`, the seam copy /// mode's refresh actually calls, so it also covers `*compilation*` and /// any other owner that adopts the primitive later. #[test] fn acc16d_a_generated_write_notifies_the_window_that_displays_it() { let mut state = EditorState::new_with_roots(&crate::iso::roots()); exec( &state, r" GEN = pmacs.buffer.create('*generated-probe*') pmacs.buffer.set_generated_contents(GEN, 'alpha\nbeta\ngamma\ndelta\nepsilon\n') pmacs.window.switch_buffer(GEN) ", ); let painted = render_active_window_to_grid(&mut state, 6, 20); assert_eq!( grid_row(&painted, 0, 20), "alpha", "precondition: the window paints the generated buffer" ); exec( &state, r"pmacs.buffer.set_generated_contents(GEN, 'CHANGED\n')", ); let painted = render_active_window_to_grid(&mut state, 6, 20); assert_eq!( grid_row(&painted, 0, 20), "CHANGED", "the window must paint the refreshed contents" ); assert_eq!( grid_row(&painted, 1, 20), "", "and nothing of the longer contents it replaced" ); } /// Review round 3, P1, CRDT half. The same dropped fan-out also skips /// `queue_daemon_origin_crdt_op`, so replica mirrors never import the /// owner's write and their optimistic edits are generated against content /// the owner has already replaced. /// /// Gated because `upgrade_to_crdt` is — and therefore dark in CI, which /// never enables the feature. The default-configuration half above is the /// one that actually runs there. #[cfg(feature = "crdt")] #[test] fn acc16e_a_refresh_queues_the_owners_write_for_replica_mirrors() { let mut state = EditorState::new_with_roots(&crate::iso::roots()); let terminal = open_fill_terminal(&mut state); focus_terminal(&state, terminal); exec(&state, "pmacs.terminal.copy_mode(TERM_BUF)"); let snapshot = snapshot_buffer_id(&state); { let core = state.core.borrow(); let mut reg = core.registry.borrow_mut(); let buffer = reg.get_mut(snapshot).expect("snapshot buffer"); // `read_only` refuses the upgrade's own bookkeeping path the same // way it refuses everything else, so lift it around the upgrade. buffer.set_read_only(false); buffer.upgrade_to_crdt(2).expect("upgrade"); buffer.set_read_only(true); } state.core.borrow_mut().pending_crdt_ops.clear(); emit_into_child(&mut state, terminal, "MIRRORME"); exec(&state, "pmacs.terminal.copy_mode(TERM_BUF)"); let queued: Vec<_> = state .core .borrow() .pending_crdt_ops .iter() .map(|(_, id, _)| *id) .collect(); assert!( queued.contains(&snapshot), "the owner's refresh must be queued for broadcast; queued: {queued:?}" ); state.process_supervisor.borrow_mut().shutdown(); } /// Acceptance 18: re-invoking refreshes in place, and the lifecycle runs /// both directions. #[test] fn acc18_reinvoke_refreshes_in_place_and_lifecycle_runs_both_ways() { let mut state = EditorState::new_with_roots(&crate::iso::roots()); let terminal = open_fill_terminal(&mut state); focus_terminal(&state, terminal); exec(&state, "pmacs.terminal.copy_mode(TERM_BUF)"); let count_after_first = buffer_count(&state); assert!( !buffer_text_by_name(&state, SNAPSHOT_NAME) .expect("snapshot") .contains("REINVOKE"), "precondition: the marker has not been emitted yet" ); // Advance the world, then re-invoke. Counting buffers alone is // vacuous: it passes with the render replaced by a no-op, so the // refresh must be observed by CONTENT that only exists after the // first snapshot was taken. emit_into_child(&mut state, terminal, "REINVOKE"); exec(&state, "pmacs.terminal.copy_mode(TERM_BUF)"); assert!( buffer_text_by_name(&state, SNAPSHOT_NAME) .expect("snapshot") .contains("REINVOKE"), "re-invoking must actually re-serialize, not just reuse the buffer" ); exec(&state, "pmacs.terminal.copy_mode(TERM_BUF)"); assert_eq!( buffer_count(&state), count_after_first, "...and it must refresh IN PLACE, not accumulate buffers" ); // Killing the snapshot alone leaves the terminal running. exec( &state, &format!( r" for _, id in ipairs(pmacs.buffer.list()) do local ok, d = pcall(pmacs.describe.buffer, id) if ok and d and d.name == {SNAPSHOT_NAME:?} then pmacs.buffer.kill(id) end end " ), ); assert!( state.terminal_manager.borrow().is_terminal(terminal), "killing the snapshot must leave the terminal untouched" ); assert!( buffer_text_by_name(&state, SNAPSHOT_NAME).is_none(), "the snapshot buffer is gone" ); // ...and it can be rebuilt afterwards. exec(&state, "pmacs.terminal.copy_mode(TERM_BUF)"); assert!( buffer_text_by_name(&state, SNAPSHOT_NAME).is_some(), "a later invoke rebuilds the snapshot" ); // Killing the terminal takes its snapshot with it. exec(&state, "pmacs.terminal.terminate(TERM_BUF)"); exec(&state, "pmacs.buffer.kill(TERM_BUF)"); assert!( buffer_text_by_name(&state, SNAPSHOT_NAME).is_none(), "killing the terminal must remove its snapshot" ); state.process_supervisor.borrow_mut().shutdown(); } /// Acceptance 19: `C-t` in a terminal — physically `C-c C-t`, because every /// unescaped key goes to the child — enters copy mode; `g` refreshes and /// `q` returns to the source terminal. #[test] fn acc19_escape_c_t_enters_copy_mode_and_g_and_q_work() { let mut state = EditorState::new_with_roots(&crate::iso::roots()); let terminal = open_fill_terminal(&mut state); focus_terminal(&state, terminal); let terminal_name = active_buffer_name(&state); // The escape, then the terminal-local binding. press(&mut state, KeyCode::Char('c'), KeyModifiers::CONTROL); press(&mut state, KeyCode::Char('t'), KeyModifiers::CONTROL); assert_eq!( active_buffer_name(&state), SNAPSHOT_NAME, "C-c C-t must enter copy mode" ); // `q` returns to the source terminal. press(&mut state, KeyCode::Char('q'), KeyModifiers::NONE); assert_eq!( active_buffer_name(&state), terminal_name, "q must return to the terminal the snapshot was taken from" ); // Now advance the world and come back WITHOUT re-invoking copy mode, // so the snapshot is genuinely stale. Comparing a quiet terminal's // snapshot against itself is vacuous — it passes with `render_snapshot` // replaced by a no-op. emit_into_child(&mut state, terminal, "AFTER-G"); exec( &state, &format!( r" for _, id in ipairs(pmacs.buffer.list()) do local ok, d = pcall(pmacs.describe.buffer, id) if ok and d and d.name == {SNAPSHOT_NAME:?} then pmacs.window.switch_buffer(id) end end " ), ); assert!( !buffer_text_by_name(&state, SNAPSHOT_NAME) .expect("snapshot") .contains("AFTER-G"), "the snapshot must still be stale before `g` — otherwise the next \ assertion proves nothing" ); press(&mut state, KeyCode::Char('g'), KeyModifiers::NONE); assert!( buffer_text_by_name(&state, SNAPSHOT_NAME) .expect("snapshot") .contains("AFTER-G"), "`g` must re-snapshot from the live terminal" ); assert_eq!( active_buffer_name(&state), SNAPSHOT_NAME, "g must not move us" ); state.process_supervisor.borrow_mut().shutdown(); } /// Acceptance 20: copy mode is additive — the live terminal's own keys are /// unchanged while a snapshot exists, and the terminal still follows its /// tail. #[test] fn acc20_live_terminal_keys_are_unchanged_while_a_snapshot_exists() { let mut state = EditorState::new_with_roots(&crate::iso::roots()); let terminal = open_fill_terminal(&mut state); let key = focus_terminal(&state, terminal); exec(&state, "pmacs.terminal.copy_mode(TERM_BUF)"); // Back to the terminal; its five live bindings must still resolve. exec(&state, "pmacs.window.switch_buffer(TERM_BUF)"); for (sequence, command) in [ ("M-w", "terminal.copy-selection"), ("M-v", "terminal.page-up"), ("C-v", "terminal.page-down"), ("M-<", "terminal.scroll-oldest"), ("M->", "terminal.scroll-bottom"), ] { let resolved: Option = eval( &state, &format!(r"local d = pmacs.describe.key({sequence:?}); return d and d.command"), ); assert_eq!( resolved.as_deref(), Some(command), "{sequence} must still be the live terminal binding" ); } // The terminal still FOLLOWS ITS TAIL while a snapshot exists. // // Read through the registered view, not `manager.snapshot(buffer)`: // that call is context-free and always returns the live screen, so it // reports "at the tail" even for a view forced to the oldest retained // row. The projected view is the only thing that can distinguish them. assert!( view_at_bottom(&state, key), "precondition: the view starts at the tail" ); emit_into_child(&mut state, terminal, "TAILMARK"); assert!( view_at_bottom(&state, key), "new child output must not knock the view off the tail" ); assert!( view_text(&state, key).contains("TAILMARK"), "the freshest output must be visible in the PROJECTED view: {:?}", view_text(&state, key) ); state.process_supervisor.borrow_mut().shutdown(); } /// Acceptance 21: the dispatch-shadow count is unchanged at six, pinned by /// the observable difference between a buffer-local keymap and a shadow — /// `describe-key` telling the truth about `g` and `q` in the snapshot. /// /// A seventh shadow would decode these keys before `KeymapStack::resolve` /// ever ran, so introspection would report whatever the global binding is /// (or nothing) while the keys behaved differently. #[test] fn acc21_describe_key_reports_the_truth_for_the_snapshot_bindings() { let mut state = EditorState::new_with_roots(&crate::iso::roots()); let terminal = open_fill_terminal(&mut state); focus_terminal(&state, terminal); exec(&state, "pmacs.terminal.copy_mode(TERM_BUF)"); for (sequence, command) in [("g", "terminal.copy-refresh"), ("q", "terminal.copy-quit")] { let resolved: Option = eval( &state, &format!(r"local d = pmacs.describe.key({sequence:?}); return d and d.command"), ); assert_eq!( resolved.as_deref(), Some(command), "describe-key must report the buffer-local {sequence} binding" ); } // And the binding really is scoped: back in the terminal, `q` is not // the copy-mode command. exec(&state, "pmacs.window.switch_buffer(TERM_BUF)"); let resolved: Option = eval( &state, r#"local d = pmacs.describe.key("q"); return d and d.command"#, ); assert_ne!( resolved.as_deref(), Some("terminal.copy-quit"), "the snapshot's q must not leak into the terminal buffer" ); state.process_supervisor.borrow_mut().shutdown(); } /// Acceptance 18a (review round 1, P1): a foreign buffer that happens to /// carry the snapshot's name is **never adopted**. /// /// `pmacs.buffer.create` takes any caller-chosen name, and snapshot writes /// use `bypass_intercept`, so found-by-name adoption clobbers a user's /// data outright. Ownership means "in copy mode's own handle table" /// (dired's F7 rule); a taken name gets a `<2>` variant instead. #[test] fn acc18a_a_foreign_same_named_buffer_is_never_adopted_or_clobbered() { let mut state = EditorState::new_with_roots(&crate::iso::roots()); let terminal = open_fill_terminal(&mut state); focus_terminal(&state, terminal); // A user's buffer, sitting exactly where the snapshot wants to go. exec( &state, &format!( r" FOREIGN = pmacs.buffer.create({SNAPSHOT_NAME:?}) FOREIGN:insert(0, 'do not clobber') " ), ); exec(&state, "pmacs.terminal.copy_mode(TERM_BUF)"); let foreign_text: String = eval(&state, r"return FOREIGN:slice(0, FOREIGN:len())"); assert_eq!( foreign_text, "do not clobber", "the foreign buffer must be untouched" ); assert_ne!( active_buffer_name(&state), SNAPSHOT_NAME, "copy mode must not display the foreign buffer" ); assert_eq!( active_buffer_name(&state), format!("{SNAPSHOT_NAME}<2>"), "a taken name must yield a unique variant" ); assert!( buffer_text_by_name(&state, &format!("{SNAPSHOT_NAME}<2>")) .expect("variant snapshot") .contains("LINE200"), "the variant is the real snapshot" ); state.process_supervisor.borrow_mut().shutdown(); } /// Acceptance 18b (review round 1, P1): snapshot identity is the terminal /// BUFFER, not its name. /// /// `TerminalManager::open` uniquifies only the *derived* name — an /// explicit `name = ...` is inserted verbatim — so two valid terminals can /// share a name. Keying snapshots by name gives them one buffer between /// them: the second invocation retargets it, `q` returns to the wrong /// terminal, and killing either one removes the shared snapshot. #[test] fn acc18b_two_same_named_terminals_get_two_independent_snapshots() { let mut state = EditorState::new_with_roots(&crate::iso::roots()); exec(&state, FILL_PROFILE); let before = terminal_buffers(&state); exec( &state, r#"TERM_A = pmacs.terminal.open { profile = "fill", name = "*same*" }"#, ); exec( &state, r#"TERM_B = pmacs.terminal.open { profile = "fill", name = "*same*" }"#, ); let fresh: Vec<_> = terminal_buffers(&state) .into_iter() .filter(|id| !before.contains(id)) .collect(); assert_eq!(fresh.len(), 2, "two terminals opened under one name"); // Distinguish them by content, since their names are identical. emit_into_child(&mut state, fresh[0], "AAAA"); emit_into_child(&mut state, fresh[1], "BBBB"); focus_terminal(&state, fresh[0]); let snap_a: String = eval( &state, r"local b = pmacs.terminal.copy_mode(TERM_A); return (pmacs.describe.buffer(b)).name", ); focus_terminal(&state, fresh[1]); let snap_b: String = eval( &state, r"local b = pmacs.terminal.copy_mode(TERM_B); return (pmacs.describe.buffer(b)).name", ); assert_ne!( snap_a, snap_b, "two terminals must not share one snapshot buffer" ); let text_a = buffer_text_by_name(&state, &snap_a).expect("snapshot A"); let text_b = buffer_text_by_name(&state, &snap_b).expect("snapshot B"); assert!( text_a.contains("AAAA") && !text_a.contains("BBBB"), "snapshot A must hold only A's output: {:?}", &text_a[text_a.len().saturating_sub(60)..] ); assert!( text_b.contains("BBBB") && !text_b.contains("AAAA"), "snapshot B must hold only B's output" ); // `q` from each snapshot returns to ITS OWN terminal, which is only // observable through the buffer id — the two names are the same. exec( &state, &format!( r" for _, id in ipairs(pmacs.buffer.list()) do local ok, d = pcall(pmacs.describe.buffer, id) if ok and d and d.name == {snap_b:?} then pmacs.window.switch_buffer(id) end end " ), ); press(&mut state, KeyCode::Char('q'), KeyModifiers::NONE); let returned_is_b: bool = eval(&state, r"return pmacs.window.buffer() == TERM_B"); assert!( returned_is_b, "q from B's snapshot must return to terminal B" ); // Killing terminal A removes only A's snapshot. exec(&state, "pmacs.terminal.terminate(TERM_A)"); exec(&state, "pmacs.buffer.kill(TERM_A)"); assert!( buffer_text_by_name(&state, &snap_a).is_none(), "A's snapshot dies with A" ); assert!( buffer_text_by_name(&state, &snap_b).is_some(), "B's snapshot must SURVIVE — a shared buffer would have gone too" ); state.process_supervisor.borrow_mut().shutdown(); } /// Copy mode refuses a non-terminal buffer rather than producing an empty /// snapshot of nothing. #[test] fn copy_mode_refuses_a_non_terminal_buffer() { let state = EditorState::new_with_roots(&crate::iso::roots()); let err = eval_err(&state, "return pmacs.terminal.copy_mode()"); assert!( err.contains("not a terminal"), "the refusal must say why: {err}" ); } /// Generated-buffer immutability Stage 1, criterion 8 [`main`] — Q#GB6's /// cursor clamp. /// /// `EditorCore::notify_buffer_edit` — the fan-out every generated write /// goes through — updated each window's `TextView` and overlays but /// clamped neither window coordinate; only `rebuild_views_for` did, and /// its doc comment said so. So a shrinking generated refresh left /// `win.cursor` past the end of the rope **indefinitely**: paint does not /// crash, and a motion command does not recover it, because motion is /// computed from the stale value. /// /// *Bite:* measured on the pre-image — cursor 29, len 2, and `C-p` leaves /// it at 29. This ships today for terminal copy mode: refresh a snapshot /// to a shorter one with the point low in the buffer and this is the /// state. Falsify by deleting the `win.cursor > len` clamp. #[test] fn acc16f_a_shrinking_generated_write_clamps_the_window_cursor() { let state = EditorState::new_with_roots(&crate::iso::roots()); exec( &state, r" GEN = pmacs.buffer.create('*generated-probe*') pmacs.buffer.set_generated_contents(GEN, 'alpha\nbeta\ngamma\ndelta\nepsilon\n') pmacs.window.switch_buffer(GEN) pmacs.editor.goto_byte(30) ", ); let (cursor, len): (i64, i64) = eval( &state, "return pmacs.editor.cursor(), pmacs.window.buffer():len()", ); assert_eq!( (cursor, len), (30, 31), "precondition: point low in a 31-byte buffer" ); exec(&state, r"pmacs.buffer.set_generated_contents(GEN, 'x\n')"); let (cursor, len): (i64, i64) = eval( &state, "return pmacs.editor.cursor(), pmacs.window.buffer():len()", ); assert_eq!(len, 2, "precondition: the buffer shrank"); assert!( cursor <= len, "the cursor must be clamped into the new rope; got {cursor} for len {len}" ); // And motion works from there: `C-p` reaches line 0, which it cannot // do from a dangling offset. exec(&state, "pmacs.editor.move_up()"); let cursor: i64 = eval(&state, "return pmacs.editor.cursor()"); assert_eq!(cursor, 0, "C-p must move to the first line"); } /// Generated-buffer immutability Stage 1, criterion 8b [`main`] — Q#GB6's /// `view_top` clamp, on a buffer that GREW. /// /// The two coordinates fail on different axes: `cursor` is a byte /// position bounded by `Buffer::len`, while `view_top` is a **line /// index** bounded by `TextView::line_count`. A replace can grow in bytes /// while collapsing many lines into one, so a clamp gated on "the buffer /// shrank" passes criterion 8 and fails here — which is why the clamp /// runs unconditionally, each coordinate against its own bound, exactly /// as `rebuild_views_for` already does. /// /// *Bite:* falsify by gating the clamp on a byte-length comparison, or by /// deleting the `view_top` half. Unlike criterion 8 this case is argued /// from the types rather than measured on `main`; the assertion below is /// the measurement. #[test] fn acc16g_a_line_collapsing_generated_write_clamps_view_top() { let state = EditorState::new_with_roots(&crate::iso::roots()); exec( &state, r" GEN = pmacs.buffer.create('*viewtop-probe*') pmacs.buffer.set_generated_contents(GEN, 'a\nb\nc\nd\ne\nf\n') pmacs.window.switch_buffer(GEN) pmacs.editor.set_view_top(5) ", ); let (top, len): (i64, i64) = eval( &state, "return pmacs.editor.view_top(), pmacs.window.buffer():len()", ); assert_eq!( (top, len), (5, 12), "precondition: scrolled to line 5 of a 12-byte, 7-line buffer" ); // 20 bytes on ONE line: longer than what it replaces, so any // "the buffer shrank" trigger is false here. exec( &state, r"pmacs.buffer.set_generated_contents(GEN, '0123456789abcdefghij')", ); let len: i64 = eval(&state, "return pmacs.window.buffer():len()"); assert_eq!(len, 20, "precondition: the buffer GREW in bytes"); let (top, lines) = { let core = state.core.borrow(); let active = core.active_window_id(); let win = core.windows.get(&active).expect("active window"); (win.view_top, win.text_view.line_count()) }; assert!( top < lines, "view_top must be clamped into the new line count; got {top} of {lines}" ); assert_eq!(top, 0, "the collapsed buffer has exactly one line"); } /// Generated-buffer immutability Stage 1 criterion 8c [`main`] — the /// selection anchor is a third window coordinate normalized by Q#GB6's /// clamp-or-clear rule. /// /// `Window::region` orders `(anchor, cursor)`, so a stale anchor above a /// clamped cursor is still the high end of the region, and /// `region_bytes` slices the rope with it. Reproduced on this branch /// before the fix: `assertion failed: end <= self.len()` at /// `src/rope.rs:145`, reached from `EditorCore::clipboard_copy`. /// /// Both outcomes matter: clamping a backward selection from 0..30 into /// 0..2 preserves the shortened region, while clamping a forward /// selection from 2..30 collapses both endpoints at 2 and clears it. /// Clearing every stale anchor passes the crash check but fails the /// first half; clamping without the collapsed check fails the second. /// /// *Bite:* delete `clamp_cursor_and_selection` from /// `notify_buffer_edit`; the first copy reaches the stale-anchor panic. #[test] fn acc16h_a_shrinking_generated_write_clamps_or_clears_the_selection() { let state = EditorState::new_with_roots(&crate::iso::roots()); exec( &state, r" GEN = pmacs.buffer.create('*anchor-probe*') pmacs.buffer.set_generated_contents(GEN, 'alpha\nbeta\ngamma\ndelta\nepsilon\n') pmacs.window.switch_buffer(GEN) ", ); { // A BACKWARD selection: anchor at the far end, point at the // start. The forward one is not a discriminator. let mut core = state.core.borrow_mut(); core.begin_selection(30); core.set_cursor_byte(0); assert_eq!( core.active_region(), Some((0, 30)), "precondition: a live 30-byte region" ); } exec(&state, r"pmacs.buffer.set_generated_contents(GEN, 'xy')"); let mut core = state.core.borrow_mut(); assert_eq!( core.active_buffer_len(), 2, "precondition: the buffer shrank" ); assert_eq!( core.active_window() .selection .map(|selection| selection.anchor), Some(2), "the stale anchor is clamped into the new extent" ); assert_eq!( core.active_region(), Some((0, 2)), "a non-collapsed selection survives as the shortened region" ); assert!( core.clipboard_copy(), "the production consumer copies the valid shortened region" ); drop(core); // The other result: cursor clamping moves 30 to the anchor at 2, so // the selected content is gone and no empty active selection remains. exec( &state, r"pmacs.buffer.set_generated_contents(GEN, 'alpha\nbeta\ngamma\ndelta\nepsilon\n')", ); { let mut core = state.core.borrow_mut(); core.begin_selection(2); core.set_cursor_byte(30); assert_eq!( core.active_region(), Some((2, 30)), "precondition: a forward 28-byte region" ); } exec(&state, r"pmacs.buffer.set_generated_contents(GEN, 'xy')"); let mut core = state.core.borrow_mut(); assert_eq!( core.active_window().selection, None, "a cursor clamp that collapses the region clears the selection" ); assert!( !core.clipboard_copy(), "there is no collapsed region to copy" ); } /// Criterion 8c's second clamp site, driven through its own real Lua /// path. /// /// `EditorCore::rebuild_views_for` had the identical defect and is a /// separate exit: the `*help*` renderer rewrites end to end and calls it /// rather than `notify_buffer_edit` (`src/lua_bindings/mod.rs:1650`, via /// `pmacs.help.show_command`). Fixing one function and not the other /// would leave a live panic reachable from `M-x` help, so this pins the /// second exit rather than trusting that one call site implies the /// other. /// /// This site also asserts both halves: a clamp can preserve the /// shortened region, and an anchor that clamps exactly onto the cursor /// clears it. *Bite:* delete `clamp_cursor_and_selection` from /// `rebuild_views_for`; the first copy panics at `src/rope.rs:145` while /// `acc16h` stays green. #[test] fn acc16i_a_shrinking_view_rebuild_clamps_or_clears_the_selection() { let state = EditorState::new_with_roots(&crate::iso::roots()); // 286 bytes, then 154: a real shrink through the help renderer. exec( &state, r" HELP = pmacs.help.show_command('cursor.down') pmacs.window.switch_buffer(HELP) ", ); let long_len: i64 = eval(&state, "return HELP:len()"); { let mut core = state.core.borrow_mut(); let anchor = u64::try_from(long_len).expect("non-negative"); core.begin_selection(anchor); core.set_cursor_byte(0); assert_eq!( core.active_region(), Some((0, anchor)), "precondition: a live region anchored at the end" ); } exec(&state, "pmacs.help.show_command('editor.quit')"); let short_len: i64 = eval(&state, "return HELP:len()"); assert!( short_len < long_len, "precondition: the help buffer shrank ({long_len} -> {short_len})" ); let short = u64::try_from(short_len).expect("non-negative"); let mut core = state.core.borrow_mut(); assert_eq!( core.active_window() .selection .map(|selection| selection.anchor), Some(short), "the anchor is clamped to the shorter help buffer" ); assert_eq!( core.active_region(), Some((0, short)), "the non-collapsed region survives the rebuild" ); assert!( core.clipboard_copy(), "copy consumes the clamped region without slicing past the rope" ); drop(core); // Grow the same help buffer, then choose an anchor that the next // short render will clamp exactly onto the cursor. exec(&state, "pmacs.help.show_command('cursor.down')"); { let mut core = state.core.borrow_mut(); let long = u64::try_from(long_len).expect("non-negative"); core.begin_selection(long); core.set_cursor_byte(short); assert_eq!( core.active_region(), Some((short, long)), "precondition: a region whose anchor exceeds the next extent" ); } exec(&state, "pmacs.help.show_command('editor.quit')"); let mut core = state.core.borrow_mut(); assert_eq!( core.active_window().selection, None, "an anchor clamp that collapses the region clears the selection" ); assert!( !core.clipboard_copy(), "the collapsed region is not retained as active-but-empty" ); } // Isolated bootstrap storage roots (see the module docs): an // integration test is compiled without `cfg(test)`, so a raw // `EditorState::new()` would read the developer's real `init.lua` and // write into their real data root. #[path = "common/iso.rs"] mod iso;