From c01cfd1e938f4816529a8ea80f3e190c635b8b36 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 7 Jul 2026 12:49:47 -0400 Subject: [PATCH] test(completion): popup acceptance suite; fix pmacs.completion table clobber Seven end-to-end tests through dispatch_key: dabbrev auto-open + TAB accept, C-n/RET second-candidate accept, Esc dismiss with fall-through typing (and no same-edit reopen), Home-breaks-anchor validation close, yank-shaped edits never auto-open (Q#C9), C-M-i below the threshold, and ctx.uri scoping through the Lua provider surface. The suite immediately caught a real wiring bug: install_completion (M4.7, runs at make_lsp_manager time) built pmacs.completion with a fresh lua.create_table(), clobbering the popup bindings installed at editor-attach time --- popup_visible was nil at runtime and the driver hook errored silently into *errors* on every edit. It now merges into the existing table, the same idiom as install_completion_framework, so installer order no longer matters. Co-Authored-By: Claude Fable 5 --- src/lua_bindings/mod.rs | 9 +- tests/completion_popup_acceptance.rs | 249 +++++++++++++++++++++++++++ 2 files changed, 257 insertions(+), 1 deletion(-) create mode 100644 tests/completion_popup_acceptance.rs diff --git a/src/lua_bindings/mod.rs b/src/lua_bindings/mod.rs index f70f2e9..0961a9e 100644 --- a/src/lua_bindings/mod.rs +++ b/src/lua_bindings/mod.rs @@ -8165,7 +8165,14 @@ fn completion_item_to_lua(lua: &Lua, item: &CompletionItem) -> mlua::Result mlua::Result<()> { let pmacs: Table = lua.globals().get("pmacs")?; - let m = lua.create_table()?; + // Merge into an existing `pmacs.completion` (the popup surface + // installs at editor-attach time, before the LSP manager exists) + // instead of clobbering it --- the same idiom as + // `install_completion_framework`. + let m: Table = match pmacs.get::>("completion")? { + Some(t) => t, + None => lua.create_table()?, + }; { let mgr = manager.clone(); diff --git a/tests/completion_popup_acceptance.rs b/tests/completion_popup_acceptance.rs new file mode 100644 index 0000000..6243c25 --- /dev/null +++ b/tests/completion_popup_acceptance.rs @@ -0,0 +1,249 @@ +//! 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"); +} + +/// The Q#C8 URI scoping: with `ctx.uri` set, the LSP provider only +/// surfaces the matching document's cached items; without it, the +/// legacy global drain applies. Driven through the Lua collect +/// surface against a hand-seeded store. +#[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"); +}