//! Config-registry acceptance (docs/config-registry-framing.md). //! //! The registry's own semantics are unit-tested in //! `src/config_registry.rs` (value/scope/epoch/listener behavior) and //! `src/lua_bindings/config.rs` (the Lua boundary). This suite covers //! only what those cannot reach: the three adopters wired into a real //! `EditorState`, the owner-defines source-location contract observed //! after actual chunk load, and `M-x describe-setting` rendering //! through the real minibuffer. //! //! Framing acceptance items covered here: 9, 19, 26, 27, 28, 29, 30, 33. //! //! Pairing is exercised by DISPATCHING keys, never //! `pmacs.command.invoke` — pair.lua reacts to `buffer.after-edit` //! with a typed-edit record that only real dispatch produces, so an //! invoke-driven test would pass vacuously against a broken gate. use std::path::PathBuf; use std::sync::atomic::{AtomicUsize, Ordering}; use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers}; use pmacs::editor::EditorState; use pmacs::lua_bindings::StateDir; use pmacs::protocol::FrontendId; // --------------------------------------------------------------------------- // Harness (mirrors tests/auto_pair_acceptance.rs) // --------------------------------------------------------------------------- fn exec(s: &EditorState, src: &str) { s.lua_host.lua().load(src.to_string()).exec().unwrap(); } fn eval(s: &EditorState, src: &str) -> T { s.lua_host.lua().load(src.to_string()).eval().unwrap() } fn fresh_state_dir() -> PathBuf { static SEQ: AtomicUsize = AtomicUsize::new(0); let dir = std::env::temp_dir().join(format!( "pmacs-configreg-{}-{}", std::process::id(), SEQ.fetch_add(1, Ordering::Relaxed) )); std::fs::create_dir_all(&dir).unwrap(); dir } fn editor(state_dir: &std::path::Path) -> EditorState { let s = EditorState::new(); s.lua_host.lua().remove_app_data::(); s.lua_host .lua() .set_app_data(StateDir(state_dir.to_path_buf())); // Language DETECTION must work; server SPAWNING must not (rust and // python carry default configs and the after-load hook would spawn // real servers). exec(&s, "pmacs.lsp.config = {}"); s } fn write_file(dir: &std::path::Path, name: &str, body: &str) -> String { let p = dir.join(name); std::fs::write(&p, body).unwrap(); p.display().to_string() } fn key(code: KeyCode, mods: KeyModifiers) -> KeyEvent { KeyEvent { code, modifiers: mods, kind: KeyEventKind::Press, state: KeyEventState::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 press(s: &mut EditorState, code: KeyCode) { s.dispatch_key(FrontendId::LOCAL, key(code, KeyModifiers::NONE)); } fn active_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() } /// `show_help_text` ends by switching the window to `*help*`, so after /// a describe command the active buffer IS the help buffer. Asserting /// through the active buffer also proves the switch happened, which a /// direct by-name lookup would silently tolerate skipping. fn help_text(s: &EditorState) -> String { let name: String = eval(s, "return pmacs.window.buffer():name()"); assert_eq!(name, "*help*", "the describe command must display *help*"); active_text(s) } // --------------------------------------------------------------------------- // Item 26 / 9 — owner-defines, observed after real chunk load // --------------------------------------------------------------------------- #[test] fn builtin_defines_succeed_at_chunk_load_and_report_their_owning_module() { let s = editor(&fresh_state_dir()); // Item 26: the define calls at the top of pair.lua / editops.lua / // autosave.lua ran during EditorState::new, which is only possible // if pmacs.config was populated before the first runtime chunk. for name in [ "editing.auto-pair", "editing.trim-on-save", "autosave.interval-ms", ] { let known: bool = eval( &s, &format!("return pmacs.config.describe({name:?}) ~= nil"), ); assert!(known, "{name} must be defined by its owning module"); } // Item 9: each definition's SourceLocation points at the module // that owns the setting, not at a shared helper. This is exactly // what a centralized define table in a config.lua would have // broken (framing Q#CR14). let pair_src: String = eval( &s, "return pmacs.config.describe('editing.auto-pair').source", ); assert!( pair_src.contains("pair.lua"), "editing.auto-pair must report pair.lua as its source, got {pair_src:?}" ); let trim_src: String = eval( &s, "return pmacs.config.describe('editing.trim-on-save').source", ); assert!( trim_src.contains("editops.lua"), "editing.trim-on-save must report editops.lua, got {trim_src:?}" ); let auto_src: String = eval( &s, "return pmacs.config.describe('autosave.interval-ms').source", ); assert!( auto_src.contains("autosave.lua"), "autosave.interval-ms must report autosave.lua, got {auto_src:?}" ); } // --------------------------------------------------------------------------- // Item 29 — the flagship: per-buffer auto-pair, the feature this arc exists for // --------------------------------------------------------------------------- #[test] fn auto_pair_off_buffer_locally_suppresses_only_that_buffer() { let dir = fresh_state_dir(); let mut s = editor(&dir); let a = write_file(&dir, "a.rs", ""); let b = write_file(&dir, "b.rs", ""); // Two buffers of the SAME language — so a language-keyed // implementation could not pass this test. The handle is stashed in // a Lua global because buffer handles are userdata, not ids. exec(&s, &format!("BUF_A = pmacs.buffer.find_or_open({a:?})")); exec(&s, &format!("pmacs.buffer.find_or_open({b:?})")); // Turn pairing off in buffer A only. exec( &s, "pmacs.config.set_local(BUF_A, 'editing.auto-pair', false)", ); // B (untouched) still pairs. exec(&s, "pmacs.editor.goto_byte(0)"); type_str(&mut s, "("); assert_eq!( active_text(&s), "()", "a buffer with no local override still pairs" ); // A (overridden) does not. exec(&s, &format!("pmacs.buffer.find_or_open({a:?})")); exec(&s, "pmacs.editor.goto_byte(0)"); type_str(&mut s, "("); assert_eq!( active_text(&s), "(", "the buffer-local override must suppress pairing in THIS buffer" ); } #[test] fn auto_pair_off_globally_suppresses_everywhere() { let dir = fresh_state_dir(); let mut s = editor(&dir); let a = write_file(&dir, "a.rs", ""); exec(&s, &format!("pmacs.buffer.find_or_open({a:?})")); exec(&s, "pmacs.config.set('editing.auto-pair', false)"); exec(&s, "pmacs.editor.goto_byte(0)"); type_str(&mut s, "("); assert_eq!(active_text(&s), "(", "a global false suppresses pairing"); } #[test] fn auto_pair_defaults_on_so_the_migration_changed_no_default() { let dir = fresh_state_dir(); let mut s = editor(&dir); let a = write_file(&dir, "a.rs", ""); exec(&s, &format!("pmacs.buffer.find_or_open({a:?})")); exec(&s, "pmacs.editor.goto_byte(0)"); type_str(&mut s, "("); assert_eq!(active_text(&s), "()", "pairing is on by default"); } // --------------------------------------------------------------------------- // Item 13 — the purge runs on the REAL buffer-death path // --------------------------------------------------------------------------- #[test] fn killing_a_buffer_through_the_real_path_purges_its_locals() { // Review round 1, finding 4. Every other test of the purge calls // `ConfigRegistry::remove_buffer` directly, so deleting the three // lines wired into `after_buffer_removed` would leave them all // green. This drives `pmacs.buffer.remove`, which is the production // route (`remove_buffer_and_fire` -> `after_buffer_removed`), and // fails if that wiring is absent. // // The assertion reads through the DEAD handle on purpose: BufferIds // are never reused (buffer_registry.rs), so a stale id cannot alias // a later buffer, and `is_set` against it reports exactly whether // the registry still holds that buffer's map. let dir = fresh_state_dir(); let s = editor(&dir); let a = write_file(&dir, "a.rs", ""); exec(&s, &format!("DEAD = pmacs.buffer.find_or_open({a:?})")); exec( &s, "pmacs.config.set_local(DEAD, 'editing.auto-pair', false)", ); assert!( eval::(&s, "return pmacs.config.is_set('editing.auto-pair', DEAD)"), "precondition: the buffer-local override is stored" ); // Switch away first so killing the buffer cannot leave the window // pointing at a dead buffer, then remove it through the real path. exec(&s, "pmacs.buffer.remove(DEAD)"); assert!( !eval::(&s, "return pmacs.config.is_set('editing.auto-pair', DEAD)"), "the buffer's locals must be purged when it is removed" ); assert!( eval::(&s, "return pmacs.config.get('editing.auto-pair', DEAD)"), "and resolution falls back to the global default" ); } // --------------------------------------------------------------------------- // Items 27 / 28 — the migration wrappers keep their legacy coercion (F4) // --------------------------------------------------------------------------- #[test] fn trim_on_save_wrapper_and_registry_are_interchangeable_both_ways() { let s = editor(&fresh_state_dir()); // Wrapper write observed by the registry. exec(&s, "pmacs.editops.trim_on_save(true)"); let via_registry: bool = eval(&s, "return pmacs.config.get('editing.trim-on-save')"); assert!(via_registry, "the wrapper's write must reach the registry"); // Registry write observed by the wrapper. exec(&s, "pmacs.config.set('editing.trim-on-save', false)"); let via_wrapper: bool = eval(&s, "return pmacs.editops.trim_on_save()"); assert!( !via_wrapper, "the registry's write must be visible through the wrapper" ); } #[test] fn trim_on_save_honors_a_buffer_local_override() { // Review round 1, finding 2. The save hook resolves against the // buffer being saved, so `set_local` is a real per-buffer switch // rather than a stored value nothing ever reads. let dir = fresh_state_dir(); let s = editor(&dir); let a = write_file(&dir, "a.rs", ""); exec(&s, &format!("BUF = pmacs.buffer.find_or_open({a:?})")); // The content must be INSERTED, not merely present on disk: // `save()` no-ops on an unmodified buffer, so a freshly-opened // buffer would leave the file byte-identical and this test would // pass without the save hook ever running. exec(&s, r#"BUF:insert(0, "keep me \n")"#); // Globally on, but off for this buffer: trailing space survives. exec(&s, "pmacs.config.set('editing.trim-on-save', true)"); exec( &s, "pmacs.config.set_local(BUF, 'editing.trim-on-save', false)", ); exec(&s, "pmacs.command.invoke('buffer.save')"); assert_eq!( std::fs::read_to_string(&a).unwrap(), "keep me \n", "a buffer-local false must suppress trimming for this buffer" ); } #[test] fn trim_on_save_still_falls_back_to_the_global_value() { // The other half of finding 2's fix, and its regression guard: // now that the hook passes a buffer, a broken fallback would make // the global setting silently stop working. A separate editor and // file because `save()` no-ops on an unmodified buffer, so the two // cases cannot share one save cycle. let dir = fresh_state_dir(); let s = editor(&dir); let a = write_file(&dir, "a.rs", ""); exec(&s, &format!("BUF = pmacs.buffer.find_or_open({a:?})")); exec(&s, r#"BUF:insert(0, "trim me \n")"#); exec(&s, "pmacs.config.set('editing.trim-on-save', true)"); exec(&s, "pmacs.command.invoke('buffer.save')"); assert_eq!( std::fs::read_to_string(&a).unwrap(), "trim me\n", "with no buffer-local override the global setting must still apply" ); } #[test] fn trim_on_save_keeps_its_lenient_truthiness() { // F4: the registry is strict (a real boolean or nothing), but this // legacy setter has always accepted anything that is not literally // `false`. A thin wrapper over a strict `set` would raise here. let s = editor(&fresh_state_dir()); exec(&s, "pmacs.editops.trim_on_save('yes')"); let on: bool = eval(&s, "return pmacs.config.get('editing.trim-on-save')"); assert!(on, "a non-false argument must still turn trimming on"); exec(&s, "pmacs.editops.trim_on_save(false)"); let off: bool = eval(&s, "return pmacs.config.get('editing.trim-on-save')"); assert!(!off, "a literal false must still turn it off"); } #[test] fn interval_ms_keeps_flooring_a_fractional_argument() { // F4 again: `integer` demands exactness, so the wrapper must floor // BEFORE handing the value over. Pre-migration this returned 1500. let s = editor(&fresh_state_dir()); let got: i64 = eval(&s, "return pmacs.autosave.interval_ms(1500.7)"); assert_eq!(got, 1500, "a fractional interval floors, it does not raise"); let stored: i64 = eval(&s, "return pmacs.config.get('autosave.interval-ms')"); assert_eq!(stored, 1500, "and the floored value is what was stored"); } #[test] fn interval_ms_still_raises_below_the_floor() { let s = editor(&fresh_state_dir()); let raised: bool = eval( &s, "local ok = pcall(pmacs.autosave.interval_ms, 500); return not ok", ); assert!(raised, "sub-floor intervals must still raise"); let unchanged: i64 = eval(&s, "return pmacs.autosave.interval_ms()"); assert_eq!(unchanged, 30000, "a rejected set leaves the value alone"); } // --------------------------------------------------------------------------- // Item 30 — a direct registry write is what the tick will read // --------------------------------------------------------------------------- #[test] fn interval_change_through_the_registry_is_visible_to_the_cadence_reader() { // The tick re-reads `pmacs.config.get("autosave.interval-ms")` every // frame rather than a module-local, so a mid-session change through // EITHER path applies without a restart. Asserting through the // wrapper's getter proves the module-local is really gone: a stale // upvalue would still report 30000 here. let s = editor(&fresh_state_dir()); exec(&s, "pmacs.config.set('autosave.interval-ms', 5000)"); let seen: i64 = eval(&s, "return pmacs.autosave.interval_ms()"); assert_eq!( seen, 5000, "a direct registry write must be what the cadence reads" ); } // --------------------------------------------------------------------------- // Item 19 — user config runs after builtins define, so a set in init.lua lands // --------------------------------------------------------------------------- #[test] fn a_set_in_user_config_position_is_observed_by_the_consumer() { // EditorState::new does not load user config in test builds, so // this drives the same ORDER explicitly: every builtin has defined, // and a user-config-shaped `set` now runs against those names and // is observed by the adopter that owns each one. let dir = fresh_state_dir(); let mut s = editor(&dir); exec( &s, r#" pmacs.config.set("editing.auto-pair", false) pmacs.config.set("editing.trim-on-save", true) pmacs.config.set("autosave.interval-ms", 9000) "#, ); assert!( eval::(&s, "return pmacs.editops.trim_on_save()"), "editops observes the user-config write" ); assert_eq!( eval::(&s, "return pmacs.autosave.interval_ms()"), 9000, "autosave observes the user-config write" ); let a = write_file(&dir, "a.rs", ""); exec(&s, &format!("pmacs.buffer.find_or_open({a:?})")); exec(&s, "pmacs.editor.goto_byte(0)"); type_str(&mut s, "("); assert_eq!( active_text(&s), "(", "pair.lua observes the user-config write" ); } // --------------------------------------------------------------------------- // Item 33 — M-x describe-setting renders into *help* // --------------------------------------------------------------------------- #[test] fn describe_setting_renders_into_help_with_the_source_location() { let s0 = editor(&fresh_state_dir()); let mut s = s0; exec(&s, "pmacs.command.invoke('editor.describe-setting')"); type_str(&mut s, "editing.auto-pair"); press(&mut s, KeyCode::Enter); let text = help_text(&s); assert!( text.contains("Setting: editing.auto-pair"), "*help* must carry the setting header, got {text:?}" ); assert!( text.contains("pair.lua"), "the rendered source location must name the owning module, got {text:?}" ); assert!( text.contains("Type:") && text.contains("boolean"), "the type must be rendered, got {text:?}" ); assert!( text.contains("Mutability:") && text.contains("live"), "mutability must be rendered, got {text:?}" ); } #[test] fn describe_setting_reports_an_unknown_name_in_the_status_line() { // `describe` RAISES NotFound for an undefined name rather than // returning nil (define-before-set, Q#CR10), so the command must // pcall — without it this dispatch would surface a Lua traceback. let s0 = editor(&fresh_state_dir()); let mut s = s0; exec(&s, "pmacs.command.invoke('editor.describe-setting')"); type_str(&mut s, "editing.no-such-setting"); press(&mut s, KeyCode::Enter); let status = s.core.borrow().status.clone(); assert!( format!("{status:?}").contains("no such setting"), "an unknown name must report cleanly, got {status:?}" ); } #[test] fn describe_setting_shows_a_buffer_local_override_when_one_exists() { let dir = fresh_state_dir(); let mut s = editor(&dir); let a = write_file(&dir, "a.rs", ""); exec(&s, &format!("pmacs.buffer.find_or_open({a:?})")); exec( &s, "pmacs.config.set_local(pmacs.window.buffer(), 'editing.auto-pair', false)", ); exec(&s, "pmacs.command.invoke('editor.describe-setting')"); type_str(&mut s, "editing.auto-pair"); press(&mut s, KeyCode::Enter); let text = help_text(&s); assert!( text.contains("Buffer-local override:"), "an existing buffer-local override must be reported, got {text:?}" ); }