pmacs/tests/kill_ring_acceptance.rs

858 lines
29 KiB
Rust

//! Kill-ring acceptance (Arc 2, docs/kill-ring-framing.md rev 3).
//!
//! Drives the real dispatch surfaces: `dispatch_key` for chords (both
//! "frontends" via distinct `FrontendId`s — an unregistered id falls
//! back to LOCAL's view, sharing the buffer while keeping its own
//! command boundary, which is exactly the shared-ring interleaving
//! shape), `dispatch_mouse` / `dispatch_pointer` for the pointer
//! boundary rows, and the real minibuffer for `M-x`.
//!
//! The daemon-side rows (optimistic CRDT edits, the unified
//! authenticated-source paste) are covered by unit tests next to
//! `handle_remote_crdt_op` / `handle_inbound_paste` in `src/daemon.rs`.
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers};
use pmacs::editor::EditorState;
use pmacs::protocol::FrontendId;
fn key(code: KeyCode, mods: KeyModifiers) -> KeyEvent {
KeyEvent {
code,
modifiers: mods,
kind: KeyEventKind::Press,
state: KeyEventState::NONE,
}
}
fn ctrl(s: &mut EditorState, c: char) {
s.dispatch_key(
FrontendId::LOCAL,
key(KeyCode::Char(c), KeyModifiers::CONTROL),
);
}
fn alt(s: &mut EditorState, c: char) {
s.dispatch_key(FrontendId::LOCAL, key(KeyCode::Char(c), KeyModifiers::ALT));
}
fn press(s: &mut EditorState, code: KeyCode) {
s.dispatch_key(FrontendId::LOCAL, key(code, KeyModifiers::NONE));
}
fn type_str(s: &mut EditorState, text: &str) {
for ch in text.chars() {
s.dispatch_key(
FrontendId::LOCAL,
key(KeyCode::Char(ch), KeyModifiers::NONE),
);
}
}
fn exec(s: &EditorState, src: &str) {
s.lua_host.lua().load(src.to_string()).exec().unwrap();
}
fn eval<T: mlua::FromLuaMulti>(s: &EditorState, src: &str) -> T {
s.lua_host.lua().load(src.to_string()).eval().unwrap()
}
fn ring(s: &EditorState) -> Vec<String> {
eval(s, "return pmacs.killring.list()")
}
fn buffer_text(s: &EditorState) -> String {
let b: mlua::String = eval(
s,
"local b = pmacs.window.buffer(); return b:slice(0, b:len())",
);
String::from_utf8_lossy(&b.as_bytes()).into_owned()
}
fn status(s: &EditorState) -> String {
s.core.borrow().status.clone()
}
/// Fresh editor whose scratch buffer holds `text`, cursor at 0.
fn editor_with(text: &str) -> EditorState {
let mut s = EditorState::new();
type_str(&mut s, text);
exec(&s, "pmacs.editor.goto_byte(0)");
s
}
// ---------------------------------------------------------------------------
// Chain mechanics
// ---------------------------------------------------------------------------
#[test]
fn kill_line_kills_to_eol_and_the_newline_separately() {
let mut s = editor_with("hello\nworld");
ctrl(&mut s, 'k'); // kills "hello"
assert_eq!(buffer_text(&s), "\nworld");
assert_eq!(ring(&s), vec!["hello"]);
// Cursor now sits at the newline: C-k kills the newline itself —
// and, being consecutive, APPENDS.
ctrl(&mut s, 'k');
assert_eq!(buffer_text(&s), "world");
assert_eq!(ring(&s), vec!["hello\n"], "consecutive C-k appends");
}
#[test]
fn consecutive_kills_build_one_entry_and_sync_the_clipboard() {
let mut s = editor_with("one\ntwo\nthree\n");
ctrl(&mut s, 'k'); // "one"
ctrl(&mut s, 'k'); // "\n"
ctrl(&mut s, 'k'); // "two"
assert_eq!(ring(&s), vec!["one\ntwo"]);
// OS slot mirrors the appended head (Q#KR4).
let slot: String = eval(&s, "return pmacs.editor.clipboard_get()");
assert_eq!(slot, "one\ntwo");
}
#[test]
fn movement_breaks_the_kill_chain() {
let mut s = editor_with("one\ntwo\n");
ctrl(&mut s, 'k'); // "one"; buffer now "\ntwo\n"
press(&mut s, KeyCode::Down); // a cursor command: chain broken
exec(&s, "pmacs.editor.goto_byte(1)"); // start of "two"
ctrl(&mut s, 'k'); // "two"
assert_eq!(ring(&s), vec!["two", "one"], "two entries, no append");
}
#[test]
fn self_insert_breaks_the_kill_chain() {
let mut s = editor_with("one\ntwo\n");
ctrl(&mut s, 'k'); // "one" (line now "\ntwo\n", cursor 0)
type_str(&mut s, "x"); // buffer "x\ntwo\n"
ctrl(&mut s, 'k'); // kills "" ... wait: cursor after 'x' is 1, at "\n"
// cursor sits at the newline → kills it.
assert_eq!(ring(&s), vec!["\n", "one"], "self-insert broke the chain");
}
#[test]
fn an_unbound_key_breaks_the_kill_chain() {
let mut s = editor_with("one\ntwo\n");
ctrl(&mut s, 'k');
press(&mut s, KeyCode::F(12)); // unbound, not printable
assert!(status(&s).contains("not bound"));
exec(&s, "pmacs.editor.goto_byte(1)");
ctrl(&mut s, 'k');
assert_eq!(ring(&s).len(), 2, "unbound key broke the chain");
}
#[test]
fn failed_kill_does_not_leave_an_appendable_chain() {
let mut s = editor_with("one\ntwo\n");
ctrl(&mut s, 'k'); // ring: ["one"]
ctrl(&mut s, 'w'); // no region → fails, clears last_kill_id
assert!(status(&s).contains("no region"));
assert_eq!(ring(&s), vec!["one"], "failed kill pushed nothing");
ctrl(&mut s, 'k'); // kills "\n"
assert_eq!(
ring(&s),
vec!["\n", "one"],
"a kill after a FAILED kill pushes fresh (no stale append)"
);
}
// ---------------------------------------------------------------------------
// M-x semantics (the three-direction matrix, Q#KR2)
// ---------------------------------------------------------------------------
fn m_x(s: &mut EditorState, name: &str) {
alt(s, 'x');
type_str(s, name);
press(s, KeyCode::Enter);
}
#[test]
fn m_x_kill_after_keybound_kill_does_not_append() {
let mut s = editor_with("one\ntwo\nthree\n");
ctrl(&mut s, 'k'); // "one"
m_x(&mut s, "edit.kill-line"); // kills "\n" — but must NOT append
assert_eq!(
ring(&s),
vec!["\n", "one"],
"the minibuffer interaction breaks the chain (Emacs semantics)"
);
}
#[test]
fn keybound_kill_after_m_x_kill_appends() {
let mut s = editor_with("one\ntwo\nthree\n");
m_x(&mut s, "edit.kill-line"); // "one" — invoke_interactive stamps it
ctrl(&mut s, 'k'); // "\n" — last_command is edit.kill-line → append
assert_eq!(
ring(&s),
vec!["one\n"],
"execute-extended-command sets this-command (Emacs semantics)"
);
}
#[test]
fn m_x_kill_twice_does_not_append() {
let mut s = editor_with("one\ntwo\nthree\n");
m_x(&mut s, "edit.kill-line");
m_x(&mut s, "edit.kill-line");
assert_eq!(ring(&s).len(), 2, "each M-x interposes execute-command");
}
// ---------------------------------------------------------------------------
// Yank / yank-pop
// ---------------------------------------------------------------------------
#[test]
fn yank_inserts_the_head_and_yank_pop_cycles_and_wraps() {
let mut s = editor_with("one\ntwo\nthree\n");
ctrl(&mut s, 'k'); // "one"
press(&mut s, KeyCode::Down); // break chain
exec(&s, "pmacs.editor.goto_byte(1)");
ctrl(&mut s, 'k'); // rest of "two"-line from byte 1: "wo"... careful
// (buffer was "\ntwo\nthree\n"; byte 1 = 't'; kills "two"[1..] = "wo")
let r = ring(&s);
assert_eq!(r.len(), 2);
let newest = r[0].clone(); // "wo"
let oldest = r[1].clone(); // "one"
// Yank at the end of the buffer.
let len: i64 = eval(&s, "local b = pmacs.window.buffer(); return b:len()");
exec(&s, &format!("pmacs.editor.goto_byte({len})"));
ctrl(&mut s, 'y');
assert!(buffer_text(&s).ends_with(&newest), "C-y yanks the head");
let cursor_after_yank: i64 = eval(&s, "return pmacs.editor.cursor()");
assert_eq!(
cursor_after_yank,
len + i64::try_from(newest.len()).unwrap()
);
// M-y replaces with the older entry...
alt(&mut s, 'y');
assert!(buffer_text(&s).ends_with(&oldest), "M-y rotates to older");
let cursor: i64 = eval(&s, "return pmacs.editor.cursor()");
assert_eq!(
cursor,
len + i64::try_from(oldest.len()).unwrap(),
"cursor at end of rotation"
);
// ...and wraps back to the newest.
alt(&mut s, 'y');
assert!(buffer_text(&s).ends_with(&newest), "M-y wraps");
}
#[test]
fn yank_pop_without_a_yank_refuses_and_stays_refused() {
let mut s = editor_with("one\ntwo\n");
ctrl(&mut s, 'k');
let before = buffer_text(&s);
alt(&mut s, 'y');
assert!(status(&s).contains("not a yank"));
assert_eq!(buffer_text(&s), before, "no edit on refusal");
// A second M-y must not ride the first one's name-stamp (Q#KR7):
// last_command IS edit.yank-pop now, but no session exists.
alt(&mut s, 'y');
assert!(status(&s).contains("not a yank"));
assert_eq!(buffer_text(&s), before);
}
#[test]
fn pointer_click_breaks_kill_chain_and_yank_session() {
use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
let mut s = editor_with("one\ntwo\nthree\n");
let term = pmacs::cell::CellSize::new(24, 80);
let click = |s: &mut EditorState, kind| {
s.dispatch_mouse(
FrontendId::LOCAL,
MouseEvent {
kind,
column: 1,
row: 1,
modifiers: KeyModifiers::NONE,
},
term,
);
};
// Kill side: C-k, click, C-k → two entries.
ctrl(&mut s, 'k');
click(&mut s, MouseEventKind::Down(MouseButton::Left));
click(&mut s, MouseEventKind::Up(MouseButton::Left));
ctrl(&mut s, 'k');
assert_eq!(ring(&s).len(), 2, "a click must break the kill chain");
// Yank side: C-y, click, M-y → refused.
ctrl(&mut s, 'y');
click(&mut s, MouseEventKind::Down(MouseButton::Left));
click(&mut s, MouseEventKind::Up(MouseButton::Left));
let before = buffer_text(&s);
alt(&mut s, 'y');
assert!(
status(&s).contains("not a yank"),
"click invalidates the yank"
);
assert_eq!(buffer_text(&s), before);
}
#[test]
fn wheel_scroll_does_not_break_the_kill_chain() {
use crossterm::event::{MouseEvent, MouseEventKind};
let term = pmacs::cell::CellSize::new(24, 80);
let scroll = |s: &mut EditorState, kind| {
s.dispatch_mouse(
FrontendId::LOCAL,
MouseEvent {
kind,
column: 1,
row: 1,
modifiers: KeyModifiers::NONE,
},
term,
);
};
// Case 1 — a boundary-clamped scroll (ScrollUp at the top) moves
// nothing at all: the chain holds and the next C-k appends in
// place.
let mut s = editor_with("one\ntwo\nthree\n");
ctrl(&mut s, 'k'); // "one"
scroll(&mut s, MouseEventKind::ScrollUp);
ctrl(&mut s, 'k'); // the newline
assert_eq!(ring(&s), vec!["one\n"], "no-op scroll preserves the chain");
// Case 2 — pmacs scrolling is cursor-follows-view, so a mid-buffer
// wheel moves point too. The chain STILL holds (Emacs: mwheel
// preserves last-command) and the next kill appends from the NEW
// point — one entry, not two.
let mut s = editor_with("one\ntwo\nthree\nfour\nfive\nsix\n");
ctrl(&mut s, 'k'); // "one"
scroll(&mut s, MouseEventKind::ScrollDown);
ctrl(&mut s, 'k');
assert_eq!(
ring(&s).len(),
1,
"a cursor-following scroll still preserves the chain: {:?}",
ring(&s)
);
}
#[test]
fn semantic_pointer_gesture_breaks_the_chain() {
let mut s = editor_with("one\ntwo\n");
ctrl(&mut s, 'k');
let buf_id = s.core.borrow().active_window().buffer_id;
s.dispatch_pointer(
FrontendId::LOCAL,
buf_id,
5,
pmacs::protocol::PointerKind::Down,
pmacs::protocol::Modifiers::NONE,
);
exec(&s, "pmacs.editor.goto_byte(1)");
ctrl(&mut s, 'k');
assert_eq!(
ring(&s).len(),
2,
"a semantic pointer Down breaks the chain"
);
}
// ---------------------------------------------------------------------------
// Region kills, selection yank
// ---------------------------------------------------------------------------
#[test]
fn cut_and_copy_feed_the_ring_and_yank_replaces_a_selection() {
let mut s = editor_with("alpha beta\n");
// Select "alpha" (bytes 0..5).
exec(
&s,
"pmacs.editor.goto_byte(5); pmacs.editor.begin_selection(0)",
);
alt(&mut s, 'w'); // copy
assert_eq!(ring(&s), vec!["alpha"]);
assert_eq!(buffer_text(&s), "alpha beta\n", "copy does not delete");
// Cut " beta" (bytes 5..10).
exec(
&s,
"pmacs.editor.goto_byte(10); pmacs.editor.begin_selection(5)",
);
ctrl(&mut s, 'w');
assert_eq!(buffer_text(&s), "alpha\n");
assert_eq!(ring(&s), vec![" beta", "alpha"]);
// Yank over a selection replaces it, and M-y rotates against the
// replaced range.
exec(
&s,
"pmacs.editor.goto_byte(5); pmacs.editor.begin_selection(0)",
);
ctrl(&mut s, 'y'); // "alpha" → " beta"
assert_eq!(buffer_text(&s), " beta\n");
alt(&mut s, 'y'); // rotate to "alpha"
assert_eq!(buffer_text(&s), "alpha\n");
}
// ---------------------------------------------------------------------------
// Shared-ring interleaving (two frontends)
// ---------------------------------------------------------------------------
/// A second "frontend": an unregistered id shares LOCAL's view (the
/// `active_view` fallback) but has its own command boundary and killring
/// session — the exact shape of the Q#KR4/KR7 interleaving blockers.
const B: FrontendId = FrontendId(9);
fn ctrl_as(s: &mut EditorState, fid: FrontendId, c: char) {
s.dispatch_key(fid, key(KeyCode::Char(c), KeyModifiers::CONTROL));
}
#[test]
fn interleaved_kills_never_append_across_frontends() {
let mut s = editor_with("one\ntwo\nthree\n");
ctrl(&mut s, 'k'); // A kills "one"
exec(&s, "pmacs.editor.goto_byte(1)");
ctrl_as(&mut s, B, 'k'); // B kills "wo" — B's own first kill
exec(&s, "pmacs.editor.goto_byte(2)");
ctrl(&mut s, 'k'); // A again — A's last_kill is NOT the head (B's is)
let r = ring(&s);
assert_eq!(r.len(), 3, "A-kill/B-kill/A-kill = three entries");
assert_eq!(r[1], "two", "B's entry intact — never appended onto");
}
#[test]
fn yank_pop_rotates_from_the_sessions_own_entry_despite_other_pushes() {
let mut s = editor_with("one\ntwo\nthree\n");
ctrl(&mut s, 'k'); // A: ring ["one"]
let len: i64 = eval(&s, "local b = pmacs.window.buffer(); return b:len()");
exec(&s, &format!("pmacs.editor.goto_byte({len})"));
ctrl(&mut s, 'y'); // A yanks "one"; session → entry("one")
// B pushes a new head, shifting positions but not ids.
exec(&s, "pmacs.editor.goto_byte(1)");
ctrl_as(&mut s, B, 'k'); // B kills "wo" → ring ["wo", "one"]
// A's M-y must rotate from A's OWN entry ("one" — now position 2),
// to the next-older-with-wrap = "wo". An index-based session would
// have mis-resolved after B's push.
// (B's kill edited the buffer, but before A's yanked range, so the
// session's slice-verify passes — the range shifted is upstream.)
// Note: B's kill removed bytes BEFORE the yank range, so the
// remembered {start,stop} no longer hold the yanked text → the
// invalidation guard fires instead. That IS the specified
// behavior: refuse rather than splice a shifted range.
alt(&mut s, 'y');
assert!(
status(&s).contains("changed since the yank"),
"a concurrent upstream edit invalidates rather than mis-splices: {:?}",
status(&s)
);
}
#[test]
fn yank_pop_uses_stable_ids_when_other_pushes_leave_the_range_intact() {
let mut s = editor_with("one\ntwo\n");
ctrl(&mut s, 'k'); // ring ["one"], A's chain live
let len: i64 = eval(&s, "local b = pmacs.window.buffer(); return b:len()");
exec(&s, &format!("pmacs.editor.goto_byte({len})"));
ctrl(&mut s, 'y'); // A yanks "one" at end; session entry = "one"
// B COPIES bytes 1..3 ("tw" of the post-kill buffer "\ntwo\none"):
// ring becomes ["tw", "one"], buffer untouched, so A's yanked
// range is still intact.
exec(
&s,
"pmacs.editor.begin_selection(1); pmacs.editor.goto_byte(3)",
);
s.dispatch_key(B, key(KeyCode::Char('w'), KeyModifiers::ALT));
assert_eq!(ring(&s), vec!["tw", "one"]);
// A's M-y: the session's entry ("one") sits at position 2 now;
// next-older-with-wrap is B's copy. An integer index recorded at
// yank time (position 1) would have rotated from the wrong place.
alt(&mut s, 'y');
assert!(
buffer_text(&s).ends_with("tw"),
"rotation follows the stable id, not a shifted index: {:?}",
buffer_text(&s)
);
}
#[test]
fn eviction_mid_session_invalidates_the_yank_pop() {
let mut s = editor_with("one\ntwo\nthree\nfour\n");
exec(&s, "pmacs.killring.max(2)");
ctrl(&mut s, 'k'); // ring ["one"]
let len: i64 = eval(&s, "local b = pmacs.window.buffer(); return b:len()");
exec(&s, &format!("pmacs.editor.goto_byte({len})"));
ctrl(&mut s, 'y'); // session → entry "one"
// Two copies (buffer untouched) evict "one" from a cap-2 ring.
exec(
&s,
"pmacs.editor.begin_selection(1); pmacs.editor.goto_byte(3)",
);
s.dispatch_key(B, key(KeyCode::Char('w'), KeyModifiers::ALT));
exec(
&s,
"pmacs.editor.begin_selection(5); pmacs.editor.goto_byte(8)",
);
s.dispatch_key(B, key(KeyCode::Char('w'), KeyModifiers::ALT));
let r = ring(&s);
assert_eq!(r.len(), 2);
assert!(!r.contains(&"one".to_string()), "'one' evicted");
alt(&mut s, 'y');
assert!(
status(&s).contains("expired"),
"an evicted session entry refuses cleanly: {:?}",
status(&s)
);
}
// ---------------------------------------------------------------------------
// External content, menu, hook delivery, cap
// ---------------------------------------------------------------------------
#[test]
fn externally_pasted_content_joins_the_ring_at_yank() {
let mut s = editor_with("");
ctrl(&mut s, 'k'); // fails (empty buffer) — ring stays empty
// An OS paste arrives (the daemon route sets the slot + inserts).
s.core.borrow_mut().paste_inbound(b"external").unwrap();
assert_eq!(buffer_text(&s), "external");
// The next yank notices slot ≠ head, pushes it, and yanks it.
ctrl(&mut s, 'y');
assert_eq!(ring(&s), vec!["external"]);
assert_eq!(buffer_text(&s), "externalexternal");
}
#[test]
fn menu_cut_feeds_the_ring_fires_after_edit_and_chains() {
use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
let mut s = editor_with("alpha beta\n");
exec(
&s,
r#"
_G.AE = 0
pmacs.hook.add("buffer.after-edit", function() _G.AE = _G.AE + 1 end)
"#,
);
// Select "alpha", open the context menu via right-click, invoke Cut.
exec(
&s,
"pmacs.editor.goto_byte(5); pmacs.editor.begin_selection(0)",
);
s.dispatch_mouse(
FrontendId::LOCAL,
MouseEvent {
kind: MouseEventKind::Down(MouseButton::Right),
column: 2,
row: 0,
modifiers: KeyModifiers::NONE,
},
pmacs::cell::CellSize::new(24, 80),
);
assert!(
s.core.borrow().menu_is_open(),
"right-click opened the menu"
);
// Find the "edit.cut" row in the open menu's state.
let cut_index = {
let menu = s.core.borrow().menu.clone();
let guard = menu.lock().unwrap();
guard
.as_ref()
.and_then(|m| {
m.rows.iter().position(|r| {
matches!(r, pmacs::menu::MenuRow::Item { command, .. }
if command == "edit.cut")
})
})
.expect("menu has a Cut row")
};
s.dispatch_menu_pointer(
FrontendId::LOCAL,
Some(u32::try_from(cut_index).unwrap()),
true,
);
assert_eq!(ring(&s), vec!["alpha"], "menu Cut fed the ring");
assert_eq!(buffer_text(&s), " beta\n");
let fired: i64 = eval(&s, "return _G.AE");
assert_eq!(fired, 1, "menu Cut fires after-edit exactly once (Q#KR10b)");
// The menu rotation makes the cut chain like a keybound one:
// a following C-k appends.
ctrl(&mut s, 'k');
let r = ring(&s);
assert_eq!(r[0], "alpha beta", "menu Cut then C-k appends (rotate row)");
}
#[test]
fn m_x_kill_fires_after_edit_and_keybound_does_not_double_fire() {
let mut s = editor_with("one\ntwo\n");
exec(
&s,
r#"
_G.AE = 0
pmacs.hook.add("buffer.after-edit", function() _G.AE = _G.AE + 1 end)
"#,
);
ctrl(&mut s, 'k');
let after_keybound: i64 = eval(&s, "return _G.AE");
assert_eq!(after_keybound, 1, "keybound kill fires once, no double");
m_x(&mut s, "edit.kill-line");
let after_mx: i64 = eval(&s, "return _G.AE");
assert_eq!(after_mx, 2, "M-x kill fires after-edit too (Q#KR10b)");
}
#[test]
fn cap_is_validated_and_shrink_trims() {
let s = editor_with("");
let d: i64 = eval(&s, "return pmacs.killring.max()");
assert_eq!(d, 60);
for bad in ["0/0", "math.huge", "-math.huge", "0", "-3", "'ten'", "{}"] {
let ok: bool = eval(&s, &format!("return (pcall(pmacs.killring.max, {bad}))"));
assert!(!ok, "killring.max({bad}) must be rejected");
}
let set: i64 = eval(&s, "return pmacs.killring.max(2.9)");
assert_eq!(set, 2, "floored");
// Five distinct entries via COPY over a static buffer (copies never
// delete, so the byte offsets stay put; each text is distinct so
// duplicate-of-head collapse never fires).
let mut s2 = editor_with("aa bb cc dd ee\n");
exec(&s2, "pmacs.killring.max(10)");
for i in 0..5i64 {
let lo = i * 3;
exec(
&s2,
&format!(
"pmacs.editor.begin_selection({lo}); pmacs.editor.goto_byte({})",
lo + 2
),
);
alt(&mut s2, 'w');
}
assert_eq!(ring(&s2).len(), 5);
let trimmed: i64 = eval(&s2, "return pmacs.killring.max(3)");
assert_eq!(trimmed, 3);
assert_eq!(ring(&s2).len(), 3, "shrinking the cap trims immediately");
}
#[test]
fn semantic_context_right_click_breaks_the_chain() {
let mut s = editor_with("one\ntwo\n");
ctrl(&mut s, 'k'); // "one" — chain live
// The semantic dispatcher routes PointerKind::Context straight to
// open_menu_at_byte, bypassing dispatch_pointer — the GPU
// right-click path.
let buf_id = s.core.borrow().active_window().buffer_id;
s.open_menu_at_byte(FrontendId::LOCAL, buf_id, 3);
// Dismiss the menu without invoking anything.
s.core.borrow_mut().menu_close();
ctrl(&mut s, 'k');
assert_eq!(
ring(&s).len(),
2,
"a semantic right-click must break the kill chain: {:?}",
ring(&s)
);
}
#[test]
fn rejecting_intercept_clears_the_kill_chain() {
let mut s = editor_with("one\ntwo\nthree\n");
ctrl(&mut s, 'k'); // "one" — chain live, ring ["one"]
// An intercept that rejects exactly the NEXT edit, then allows.
exec(
&s,
r#"
_G.reject_once = true
pmacs.buffer.add_intercept(pmacs.window.buffer(), function(_op)
if _G.reject_once then
_G.reject_once = false
error("rejected by test intercept")
end
return nil
end)
"#,
);
ctrl(&mut s, 'k'); // rejected — must clear the chain, push nothing
assert!(
status(&s).contains("rejected"),
"rejection is reported: {:?}",
status(&s)
);
assert_eq!(ring(&s), vec!["one"], "a rejected kill feeds nothing");
ctrl(&mut s, 'k'); // allowed again — must push FRESH, not append
assert_eq!(
ring(&s),
vec!["\n", "one"],
"the chain did not survive the rejection"
);
}
#[test]
fn transforming_intercept_does_not_feed_the_ring() {
let mut s = editor_with("alpha\nbeta\n");
// An intercept that shrinks every delete to its first byte: the
// bytes actually removed are not what C-k sliced, so pushing the
// sliced text would put never-killed bytes on the ring.
exec(
&s,
r#"
pmacs.buffer.add_intercept(pmacs.window.buffer(), function(op)
if op.kind == "delete" then
return { kind = "delete", start = op.start, ["end"] = op.start + 1 }
end
return nil
end)
"#,
);
ctrl(&mut s, 'k');
assert!(
status(&s).contains("altered"),
"transformation is reported: {:?}",
status(&s)
);
assert!(ring(&s).is_empty(), "a transformed kill feeds nothing");
// The interceptor's result stands (accepted post-hoc semantics).
assert_eq!(buffer_text(&s), "lpha\nbeta\n");
}
#[test]
fn equal_length_shifted_delete_does_not_feed_the_ring() {
let mut s = editor_with("abcdef\nghijkl\n");
// An intercept that SHIFTS every delete right by 2 bytes while
// keeping its length — a length-delta check cannot see this, and
// the ring would receive text that was never killed.
exec(
&s,
r#"
pmacs.buffer.add_intercept(pmacs.window.buffer(), function(op)
if op.kind == "delete" then
return { kind = "delete", start = op.start + 2, ["end"] = op["end"] + 2 }
end
return nil
end)
"#,
);
ctrl(&mut s, 'k'); // wanted [0,6) "abcdef"; intercept deletes [2,8)
assert!(
status(&s).contains("altered"),
"an equal-length shifted delete is detected: {:?}",
status(&s)
);
assert!(
ring(&s).is_empty(),
"never-killed text must not reach the ring: {:?}",
ring(&s)
);
// The interceptor's result stands.
assert_eq!(buffer_text(&s), "abhijkl\n");
}
#[test]
fn stop_enlarging_replace_ends_the_yank_session() {
let mut s = editor_with("one\ntwo\n");
ctrl(&mut s, 'k'); // "one"
press(&mut s, KeyCode::Down);
exec(&s, "pmacs.editor.goto_byte(1)");
ctrl(&mut s, 'k'); // "two"
// Yank at the START so the buffer extends past the yanked range —
// an end+1 range at buffer end would fail validation ("rejected")
// instead of exercising the transform path.
exec(&s, "pmacs.editor.goto_byte(0)");
ctrl(&mut s, 'y'); // session live
// An intercept that enlarges every replace's end by ONE byte: the
// replacement text still lands at s.start, so a "text appears at
// start" verify passes — but one extra byte was silently deleted.
exec(
&s,
r#"
pmacs.buffer.add_intercept(pmacs.window.buffer(), function(op)
if op.kind == "replace" then
return { kind = "replace", start = op.start, ["end"] = op["end"] + 1 }
end
return nil
end)
"#,
);
alt(&mut s, 'y');
assert!(
status(&s).contains("altered"),
"an end-enlarged replace is detected: {:?}",
status(&s)
);
// The session is dead: a further M-y refuses without editing.
let before = buffer_text(&s);
alt(&mut s, 'y');
assert!(status(&s).contains("not a yank"));
assert_eq!(buffer_text(&s), before);
}
#[test]
fn rejecting_intercept_ends_the_yank_session() {
let mut s = editor_with("one\ntwo\n");
ctrl(&mut s, 'k'); // "one"
press(&mut s, KeyCode::Down);
exec(&s, "pmacs.editor.goto_byte(1)");
ctrl(&mut s, 'k'); // "two"
assert_eq!(ring(&s).len(), 2);
let len: i64 = eval(&s, "local b = pmacs.window.buffer(); return b:len()");
exec(&s, &format!("pmacs.editor.goto_byte({len})"));
ctrl(&mut s, 'y'); // session live
// Reject the next edit (the M-y replace).
exec(
&s,
r#"
_G.reject_once = true
pmacs.buffer.add_intercept(pmacs.window.buffer(), function(_op)
if _G.reject_once then
_G.reject_once = false
error("rejected by test intercept")
end
return nil
end)
"#,
);
alt(&mut s, 'y'); // rejected — must END the session, not throw through
assert!(
status(&s).contains("rejected"),
"rejection reported: {:?}",
status(&s)
);
// A second M-y must refuse on "no session", not reuse the dead one.
let before = buffer_text(&s);
alt(&mut s, 'y');
assert!(
status(&s).contains("not a yank"),
"the rejected session is gone: {:?}",
status(&s)
);
assert_eq!(buffer_text(&s), before, "no splice from a dead session");
}
#[test]
fn frontend_detached_drops_per_frontend_state() {
let mut s = editor_with("one\ntwo\n");
ctrl_as(&mut s, B, 'k'); // B kills → B has last_kill_id
let has: bool = eval(
&s,
"return pmacs.killring._debug_state(9).last_kill_id ~= nil",
);
assert!(has, "B has kill state");
// The daemon fires this on SessionDetached (Q#KR11); fire it the
// same way to exercise the Lua-side cleanup.
exec(&s, "pmacs.hook.run('frontend.detached', 9)");
let gone: bool = eval(
&s,
"local st = pmacs.killring._debug_state(9); \
return st.last_kill_id == nil and st.session == nil",
);
assert!(gone, "detach dropped B's killring state");
}