pmacs/tests/completion_popup_acceptance.rs

308 lines
11 KiB
Rust

//! In-buffer completion popup acceptance (Arc 1a) --- the phase-1
//! TUI/core path end-to-end: the Lua driver's Q#C9 auto-open policy,
//! the Q#C3 partial dispatcher shadow, and Q#C7 validated accept, all
//! driven through `dispatch_key` exactly as a terminal user would.
//!
//! The LSP provider's request path is covered by the `m4_5` fake-LSP
//! suite; these tests run hermetic (dabbrev + custom Lua providers)
//! so no server binary is needed.
//!
//! Framing: docs/in-buffer-completion-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 type_str(s: &mut EditorState, text: &str) {
for ch in text.chars() {
s.dispatch_key(
FrontendId::LOCAL,
key(KeyCode::Char(ch), KeyModifiers::NONE),
);
}
}
/// `(buffer text, popup visible?, cursor)` probed through the Lua
/// surface --- the same introspection a user-facing script would use.
fn probe(s: &EditorState) -> (String, bool, i64) {
s.lua_host
.lua()
.load(
"
local b = pmacs.window.buffer()
local text = b:slice(0, b:len())
return text, pmacs.completion.popup_visible(), pmacs.editor.cursor()
",
)
.eval()
.expect("probe buffer/popup state")
}
/// Typing a two-char prefix of an existing buffer word auto-opens the
/// popup off dabbrev (Q#C9 single-char signature), and TAB accepts:
/// the prefix is replaced by the candidate in one step.
#[test]
fn typing_opens_popup_and_tab_accepts() {
let mut s = EditorState::new();
type_str(&mut s, "hello_world ");
let (_, visible, _) = probe(&s);
assert!(
!visible,
"no popup while typing the only word in the buffer"
);
type_str(&mut s, "he");
let (_, visible, _) = probe(&s);
assert!(
visible,
"prefix `he` with dabbrev match `hello_world` opens"
);
s.dispatch_key(FrontendId::LOCAL, key(KeyCode::Tab, KeyModifiers::NONE));
let (text, visible, cursor) = probe(&s);
assert_eq!(text, "hello_world hello_world", "TAB replaces the prefix");
assert!(!visible, "accept closes the popup");
assert_eq!(cursor, 23, "cursor lands just past the inserted text");
}
/// C-n moves the highlight before RET accepts, so the second
/// candidate wins. Uses a custom provider for a deterministic order.
#[test]
fn ctrl_n_navigates_then_ret_accepts_second_candidate() {
let mut s = EditorState::new();
s.lua_host
.lua()
.load(
"
pmacs.completion.register({
name = 'test_src',
priority = 200,
fn = function()
return {
{ label = 'aardvark', kind = 'text' },
{ label = 'aardwolf', kind = 'text' },
}
end,
})
",
)
.exec()
.expect("register test provider");
type_str(&mut s, "aa");
let (_, visible, _) = probe(&s);
assert!(visible, "custom provider matches the `aa` prefix");
s.dispatch_key(
FrontendId::LOCAL,
key(KeyCode::Char('n'), KeyModifiers::CONTROL),
);
s.dispatch_key(FrontendId::LOCAL, key(KeyCode::Enter, KeyModifiers::NONE));
let (text, visible, _) = probe(&s);
assert_eq!(text, "aardwolf", "C-n selected the second candidate");
assert!(!visible);
}
/// Esc dismisses without touching the buffer, and the next printable
/// key self-inserts normally (the shadow is partial, not modal).
#[test]
fn esc_dismisses_and_typing_falls_through() {
let mut s = EditorState::new();
type_str(&mut s, "hello_world he");
let (_, visible, _) = probe(&s);
assert!(visible);
s.dispatch_key(FrontendId::LOCAL, key(KeyCode::Esc, KeyModifiers::NONE));
let (text, visible, _) = probe(&s);
assert_eq!(text, "hello_world he", "Esc leaves the buffer untouched");
assert!(!visible, "Esc dismisses");
type_str(&mut s, "x");
let (text, visible, _) = probe(&s);
assert_eq!(text, "hello_world hex", "typing after Esc self-inserts");
assert!(!visible, "the dismissed popup does not reopen off that key");
}
/// Motion that breaks the anchor invariant closes the popup via the
/// post-dispatch validation (Q#C3): Home moves the cursor before the
/// anchor.
#[test]
fn motion_before_anchor_closes_popup() {
let mut s = EditorState::new();
type_str(&mut s, "hello_world he");
let (_, visible, _) = probe(&s);
assert!(visible);
s.dispatch_key(FrontendId::LOCAL, key(KeyCode::Home, KeyModifiers::NONE));
let (text, visible, _) = probe(&s);
assert_eq!(text, "hello_world he");
assert!(!visible, "cursor before the anchor invalidates the session");
}
/// A multi-byte edit (kill-ring yank) never auto-opens the popup ---
/// the Q#C9 single-char signature rejects paste-shaped deltas.
#[test]
fn yank_shaped_edit_does_not_auto_open() {
let mut s = EditorState::new();
type_str(&mut s, "hello_world hello");
// Select the trailing word and cut it (C-w): the popup that was
// open over `hello` closes as its word dies.
for _ in 0..5 {
s.dispatch_key(FrontendId::LOCAL, key(KeyCode::Left, KeyModifiers::SHIFT));
}
s.dispatch_key(
FrontendId::LOCAL,
key(KeyCode::Char('w'), KeyModifiers::CONTROL),
);
let (text, visible, _) = probe(&s);
assert_eq!(text, "hello_world ");
assert!(!visible, "cutting the word closes the popup");
// Yank it back: one edit, five bytes --- not a typing signature.
s.dispatch_key(
FrontendId::LOCAL,
key(KeyCode::Char('y'), KeyModifiers::CONTROL),
);
let (text, visible, _) = probe(&s);
assert_eq!(text, "hello_world hello", "yank restored the word");
assert!(!visible, "a 5-byte edit must not auto-open the popup");
}
/// `completion.at-point` (C-M-i) opens deliberately, even below the
/// auto-open prefix threshold.
#[test]
fn at_point_command_opens_below_threshold() {
let mut s = EditorState::new();
type_str(&mut s, "hello_world h");
let (_, visible, _) = probe(&s);
assert!(!visible, "a 1-char prefix stays below the auto-open bar");
s.dispatch_key(
FrontendId::LOCAL,
key(
KeyCode::Char('i'),
KeyModifiers::CONTROL | KeyModifiers::ALT,
),
);
let (_, visible, _) = probe(&s);
assert!(visible, "C-M-i opens the popup on demand");
s.dispatch_key(FrontendId::LOCAL, key(KeyCode::Tab, KeyModifiers::NONE));
let (text, _, _) = probe(&s);
assert_eq!(text, "hello_world hello_world");
}
/// A multi-key prefix owns the keyboard: starting `C-x` while the
/// popup is open dismisses it, so the sequence's continuation (and a
/// `C-g` abort) reaches the dispatcher instead of the popup shadow.
#[test]
fn pending_prefix_dismisses_popup_and_keeps_dispatcher_control() {
let mut s = EditorState::new();
type_str(&mut s, "hello_world he");
let (_, visible, _) = probe(&s);
assert!(visible);
// C-x starts a prefix: the popup must close immediately...
s.dispatch_key(
FrontendId::LOCAL,
key(KeyCode::Char('x'), KeyModifiers::CONTROL),
);
let (text, visible, _) = probe(&s);
assert_eq!(text, "hello_world he", "the prefix key edits nothing");
assert!(!visible, "a pending prefix dismisses the popup");
// ...so this C-g aborts the prefix (not a popup), and typing
// afterwards self-inserts normally.
s.dispatch_key(
FrontendId::LOCAL,
key(KeyCode::Char('g'), KeyModifiers::CONTROL),
);
type_str(&mut s, "x");
let (text, _, _) = probe(&s);
assert_eq!(
text, "hello_world hex",
"after the aborted prefix, keys dispatch normally"
);
}
/// LSP-only words must still query the server: when the synchronous
/// providers return nothing at auto-open, a pending session is left
/// behind and the request fires anyway (previously the request was
/// gated on the popup having opened, so an empty dabbrev/snippet/
/// index sweep meant the server was never asked).
#[test]
fn empty_sync_sweep_still_leaves_a_pending_session() {
let mut s = EditorState::new();
// A buffer whose only word is the one being typed: dabbrev is
// structurally empty, no LSP attached. The popup cannot open...
type_str(&mut s, "qz");
let (_, visible, _) = probe(&s);
assert!(!visible, "nothing to show without providers");
// ...but the driver's session mirror must be pending rather than
// absent, so a (mocked) late LSP arrival could materialize it.
// With no attachment at all the request path no-ops; what we can
// assert end-to-end is that the state machine stays consistent:
// further typing neither crashes nor opens a bogus popup.
type_str(&mut s, "q");
let (text, visible, _) = probe(&s);
assert_eq!(text, "qzq");
assert!(!visible);
}
/// The Q#C8 URI plumbing: `ctx.uri` reaches Lua providers as the
/// ninth positional arg, so a URI-aware provider can scope itself
/// (the BUILT-IN LSP provider is stricter still: no uri → no rows).
/// Driven through the Lua collect surface with an emulating provider
/// because the real LSP store is Rust-side.
#[test]
fn collect_scopes_lsp_candidates_to_ctx_uri() {
let s = EditorState::new();
let (scoped, unscoped): (u64, u64) = s
.lua_host
.lua()
.load(
"
-- Seed the M4.7 LSP store for two URIs through a custom
-- provider is not possible (the store is Rust-side), so
-- emulate the shape: a URI-aware provider that mirrors
-- what the built-in LSP provider does with ctx.uri.
pmacs.completion.register({
name = 'uri_aware',
priority = 150,
fn = function(prefix, line, col, text, lang, root, trig, tchar, uri)
local by_uri = {
['file:///a.rs'] = { { label = 'alpha_from_a' } },
['file:///b.rs'] = { { label = 'alpha_from_b' } },
}
if uri then
return by_uri[uri] or {}
end
local all = {}
for _, items in pairs(by_uri) do
for _, it in ipairs(items) do all[#all + 1] = it end
end
return all
end,
})
local scoped = pmacs.completion.collect({
prefix = 'alpha', uri = 'file:///a.rs',
})
local unscoped = pmacs.completion.collect({ prefix = 'alpha' })
return #scoped, #unscoped
",
)
.eval()
.expect("collect with and without uri");
assert_eq!(scoped, 1, "ctx.uri reaches Lua providers (9th arg)");
assert_eq!(unscoped, 2, "no uri → all documents");
}