pmacs/tests/query_replace_acceptance.rs

283 lines
8.6 KiB
Rust

//! Query-replace acceptance (Arc 2) — the interactive phase end-to-end
//! through `dispatch_key`, exactly as a user (or a round-tripping GPU)
//! drives it: the `M-%` / `C-M-%` bindings, the y/n/!/./q vocabulary,
//! the three quit paths + nothing-matched-restores-origin, empty-to
//! deletion, offset-shift correctness, regex, the `buffer.after-edit`
//! hook firing on replaced text, and the `dispatch_idle`-false gate.
//!
//! Framing: docs/query-replace-framing.md.
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::empty(),
}
}
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() {
press(s, KeyCode::Char(ch));
}
}
/// Move the active cursor to buffer start (no default binding for it;
/// walk up then to line start, like lsp.lua's cursor-move).
fn goto_start(s: &EditorState) {
s.lua_host
.lua()
.load(
r"
while pmacs.editor.cursor_line() > 0 do pmacs.editor.move_up() end
pmacs.editor.move_line_start()
",
)
.exec()
.expect("move cursor to buffer start");
}
/// `(buffer text, active?, query-replace active?)` through the Lua
/// surface.
fn probe(s: &EditorState) -> (String, bool) {
s.lua_host
.lua()
.load(
r"
local b = pmacs.window.buffer()
return b:slice(0, b:len()), pmacs.editor.query_replace_active()
",
)
.eval()
.expect("probe query-replace state")
}
/// Drive the two minibuffer prompts a `query-replace` command opens:
/// type `from`, RET, type `to`, RET. Leaves the session in its
/// interactive phase (or finished, if `!`/no-match).
fn start_query_replace(s: &mut EditorState, from: &str, to: &str, regex: bool) {
let cmd = if regex {
"query-replace-regexp"
} else {
"query-replace"
};
s.lua_host
.lua()
.load(format!("pmacs.command.invoke('{cmd}')"))
.exec()
.expect("invoke query-replace command");
type_str(s, from);
press(s, KeyCode::Enter);
type_str(s, to);
press(s, KeyCode::Enter);
}
#[test]
fn replace_skip_and_quit_is_selective() {
let mut s = EditorState::new();
type_str(&mut s, "x x x x");
// Cursor to buffer start so all four are ahead of point.
goto_start(&s);
start_query_replace(&mut s, "x", "y", false);
let (_, active) = probe(&s);
assert!(
active,
"session is in its interactive phase on the first match"
);
press(&mut s, KeyCode::Char('y')); // replace 1st
press(&mut s, KeyCode::Char('n')); // skip 2nd
press(&mut s, KeyCode::Char('y')); // replace 3rd
press(&mut s, KeyCode::Char('q')); // quit before the 4th
let (text, active) = probe(&s);
assert_eq!(text, "y x y x", "y/n/y then quit");
assert!(!active, "q ends the session");
}
#[test]
fn bang_replaces_all_remaining() {
let mut s = EditorState::new();
type_str(&mut s, "a a a a");
goto_start(&s);
start_query_replace(&mut s, "a", "b", false);
press(&mut s, KeyCode::Char('!'));
let (text, active) = probe(&s);
assert_eq!(text, "b b b b");
assert!(!active, "! finishes the session");
}
#[test]
fn dot_replaces_current_then_quits() {
let mut s = EditorState::new();
type_str(&mut s, "a a a");
goto_start(&s);
start_query_replace(&mut s, "a", "z", false);
press(&mut s, KeyCode::Char('.')); // replace first, then quit
let (text, active) = probe(&s);
assert_eq!(text, "z a a", "only the first is replaced");
assert!(!active);
}
#[test]
fn growing_replacement_does_not_loop() {
// a → aa must not re-match the inserted text (offset-shift + the
// search-forward-past-replacement rule).
let mut s = EditorState::new();
type_str(&mut s, "a a a");
goto_start(&s);
start_query_replace(&mut s, "a", "aa", false);
press(&mut s, KeyCode::Char('!'));
let (text, _) = probe(&s);
assert_eq!(text, "aa aa aa", "each 'a' replaced exactly once");
}
#[test]
fn empty_to_deletes() {
let mut s = EditorState::new();
type_str(&mut s, "a-b-c");
goto_start(&s);
start_query_replace(&mut s, "-", "", false);
press(&mut s, KeyCode::Char('!'));
let (text, _) = probe(&s);
assert_eq!(text, "abc", "empty replacement deletes matches");
}
#[test]
fn regex_query_replace_via_binding() {
let mut s = EditorState::new();
type_str(&mut s, "a1 b2 c3");
goto_start(&s);
start_query_replace(&mut s, "[0-9]", "#", true);
press(&mut s, KeyCode::Char('!'));
let (text, _) = probe(&s);
assert_eq!(text, "a# b# c#");
}
#[test]
fn m_percent_binding_starts_query_replace() {
// The literal chord: M-% (Alt + Shift+5 → Char('%') with ALT).
let mut s = EditorState::new();
type_str(&mut s, "cat cat");
goto_start(&s);
s.dispatch_key(
FrontendId::LOCAL,
key(KeyCode::Char('%'), KeyModifiers::ALT),
);
// The from-prompt minibuffer should now be active.
let mb: bool = s
.lua_host
.lua()
.load("return pmacs.minibuffer.is_active and pmacs.minibuffer.is_active() or false")
.eval()
.unwrap_or(false);
assert!(mb, "M-% opened the query-replace from-prompt");
// Complete the flow and confirm it replaces.
type_str(&mut s, "cat");
press(&mut s, KeyCode::Enter);
type_str(&mut s, "dog");
press(&mut s, KeyCode::Enter);
press(&mut s, KeyCode::Char('!'));
let (text, _) = probe(&s);
assert_eq!(text, "dog dog", "M-% drove a full query-replace");
}
#[test]
fn c_m_percent_binding_starts_regexp_query_replace() {
// Control-meta-shifted punctuation — the chord most likely to parse
// differently across key paths (the C-c H lesson).
let mut s = EditorState::new();
type_str(&mut s, "x1 x2");
goto_start(&s);
s.dispatch_key(
FrontendId::LOCAL,
key(
KeyCode::Char('%'),
KeyModifiers::CONTROL | KeyModifiers::ALT,
),
);
let mb: bool = s
.lua_host
.lua()
.load("return pmacs.minibuffer.is_active and pmacs.minibuffer.is_active() or false")
.eval()
.unwrap_or(false);
assert!(mb, "C-M-% opened the query-replace-regexp from-prompt");
type_str(&mut s, "x[0-9]");
press(&mut s, KeyCode::Enter);
type_str(&mut s, "Q");
press(&mut s, KeyCode::Enter);
press(&mut s, KeyCode::Char('!'));
let (text, _) = probe(&s);
assert_eq!(text, "Q Q", "C-M-% drove a regexp query-replace");
}
#[test]
fn nothing_matched_leaves_buffer_untouched() {
let mut s = EditorState::new();
type_str(&mut s, "hello");
start_query_replace(&mut s, "zzz", "q", false);
let (text, active) = probe(&s);
assert_eq!(text, "hello", "no match → buffer untouched");
assert!(!active, "no match → session never stays open");
}
#[test]
fn query_replace_flips_dispatch_idle_so_gpu_round_trips() {
// While the interactive phase runs, dispatch_idle must be false so a
// semantic frontend round-trips y/n/etc. instead of self-inserting.
let mut s = EditorState::new();
type_str(&mut s, "a a");
goto_start(&s);
start_query_replace(&mut s, "a", "b", false);
assert!(
!s.dispatch_idle(),
"query-replace interactive phase forces key round-trip"
);
press(&mut s, KeyCode::Char('!'));
assert!(s.dispatch_idle(), "idle again after the session finishes");
}
#[test]
fn replace_fires_after_edit_hook() {
// The Q#QR1 hook: an LSP/syntax observer must see replaced text.
let mut s = EditorState::new();
s.lua_host
.lua()
.load(
r"
_G.EDITS = 0
pmacs.hook.add('buffer.after-edit', function() _G.EDITS = _G.EDITS + 1 end)
",
)
.exec()
.expect("install after-edit counter");
type_str(&mut s, "a a a");
goto_start(&s);
let before: i64 = s
.lua_host
.lua()
.load("return _G.EDITS")
.eval()
.expect("read counter");
start_query_replace(&mut s, "a", "b", false);
press(&mut s, KeyCode::Char('y')); // one replacement
let after: i64 = s
.lua_host
.lua()
.load("return _G.EDITS")
.eval()
.expect("read counter");
assert!(
after > before,
"buffer.after-edit fired for the replacement (before {before}, after {after})"
);
}