//! List-panel acceptance (Arc 1b phase 1) --- the `pmacs.listview` //! module end-to-end through `dispatch_key`: open/navigate/visit, //! `q` restore, the Q#P3 read-only intercept, the Q#P6 round-trip //! gate (`dispatch_idle` false while a panel is focused), and //! refresh. The references panel itself needs a live LSP and is //! validated manually / via the m4 harness; these tests drive the //! substrate hermetically. //! //! Framing: docs/lsp-panels-framing.md. //! //! Generated-buffer immutability Stage 1 //! (docs/generated-buffer-immutability-framing.md §6) adds the //! criteria below `refresh_reruns_the_source_and_reseats`: the undo //! paths the Q#P3 intercept never guarded, the Q#GB13 ownership rule, //! and the Q#GB18 identity routing that ownership makes load-bearing. use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers}; use pmacs::buffer::BufferId; 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 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 type_str(s: &mut EditorState, text: &str) { for ch in text.chars() { s.dispatch_key( FrontendId::LOCAL, key(KeyCode::Char(ch), KeyModifiers::NONE), ); } } /// `M-x RET` through the **real** minibuffer, not /// `pmacs.command.invoke`: `buffer.undo` is reachable that way on every /// buffer in the tree and no buffer-local rebinding can remove it, which /// is the whole reason the intercept idiom did not close this hole. fn m_x(s: &mut EditorState, name: &str) { alt(s, 'x'); type_str(s, name); press(s, KeyCode::Enter); } 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 status(s: &EditorState) -> String { s.core.borrow().status.clone() } fn buffer_names(s: &EditorState) -> Vec { eval( s, "local out = {}\n\ for _, id in ipairs(pmacs.buffer.list()) do\n\ out[#out + 1] = pmacs.describe.buffer(id).name\n\ end\n\ return out", ) } fn active_name(s: &EditorState) -> String { eval( s, "return pmacs.describe.buffer(pmacs.window.buffer()).name", ) } fn active_text(s: &EditorState) -> String { eval( s, "local b = pmacs.window.buffer()\nreturn b:slice(0, b:len())", ) } fn id_of(s: &EditorState, name: &str) -> BufferId { let core = s.core.borrow(); let reg = core.registry.borrow(); reg.find_by_name(name) .unwrap_or_else(|| panic!("no buffer named {name:?} in {:?}", buffer_names(s))) } /// Q#GB14: the rope lock is not observable from Lua --- `describe.buffer` /// carries `name`, `length`, `modified`, `view_count` and nothing else --- /// so every "is it locked" assertion goes through Rust. fn is_read_only(s: &EditorState, id: BufferId) -> bool { let core = s.core.borrow(); let reg = core.registry.borrow(); reg.get(id).expect("buffer in registry").is_read_only() } fn set_read_only(s: &EditorState, id: BufferId, value: bool) { let core = s.core.borrow(); let mut reg = core.registry.borrow_mut(); reg.get_mut(id) .expect("buffer in registry") .set_read_only(value); } /// Open a three-row test panel whose visits record into `_G.VISITED`. fn open_test_panel(s: &mut EditorState) { s.lua_host .lua() .load( r#" _G.VISITED = nil pmacs.listview.open { name = "*test-panel*", header = "3 items RET visit q quit", rows = { { text = "alpha", item = "A" }, { text = "beta", item = "B" }, { text = "gamma", item = "C" }, }, on_visit = function(item) _G.VISITED = item end, on_refresh = function() return { { text = "delta", item = "D" } } end, } "#, ) .exec() .expect("open test panel"); } /// `(active buffer name, buffer text, cursor line, visited)` probed /// through the Lua surface. fn probe(s: &EditorState) -> (String, String, i64, Option) { s.lua_host .lua() .load( r" local b = pmacs.window.buffer() local d = pmacs.describe.buffer(b) return d.name, b:slice(0, b:len()), pmacs.editor.cursor_line(), _G.VISITED ", ) .eval() .expect("probe panel state") } #[test] fn open_seats_cursor_and_ret_visits_the_row() { let mut s = EditorState::new(); open_test_panel(&mut s); let (name, text, line, _) = probe(&s); assert_eq!(name, "*test-panel*"); assert!(text.starts_with("3 items"), "header renders first"); assert_eq!(line, 1, "the cursor opens on the first data row"); press(&mut s, KeyCode::Char('n')); // buffer-local: cursor.down press(&mut s, KeyCode::Enter); let (_, _, _, visited) = probe(&s); assert_eq!(visited.as_deref(), Some("B"), "RET visits the second row"); } #[test] fn header_row_is_not_visitable() { let mut s = EditorState::new(); open_test_panel(&mut s); press(&mut s, KeyCode::Char('p')); // up onto the header press(&mut s, KeyCode::Enter); let (_, _, _, visited) = probe(&s); assert_eq!(visited, None, "the header maps to no item"); } #[test] fn q_restores_the_previous_buffer() { let mut s = EditorState::new(); open_test_panel(&mut s); press(&mut s, KeyCode::Char('q')); let (name, _, _, _) = probe(&s); assert_eq!(name, "*scratch*", "q returns to the buffer we came from"); } #[test] fn panel_rejects_typing() { let mut s = EditorState::new(); open_test_panel(&mut s); let (_, before, _, _) = probe(&s); press(&mut s, KeyCode::Char('z')); // unbound printable → self-insert → intercept rejects let (_, after, _, _) = probe(&s); assert_eq!(before, after, "the read-only intercept rejects self-insert"); } #[test] fn dispatch_idle_is_false_while_a_panel_is_focused() { // Q#P6: while the panel is the active buffer, semantic frontends // must round-trip every key (RET = visit, not an optimistic \n). let mut s = EditorState::new(); assert!(s.dispatch_idle(), "scratch buffer: idle"); open_test_panel(&mut s); assert!(!s.dispatch_idle(), "panel focused: keys must round-trip"); press(&mut s, KeyCode::Char('q')); assert!(s.dispatch_idle(), "restored buffer: idle again"); } #[test] fn refresh_reruns_the_source_and_reseats() { let mut s = EditorState::new(); open_test_panel(&mut s); press(&mut s, KeyCode::Char('g')); let (_, text, line, _) = probe(&s); assert!(text.contains("delta"), "g re-renders from on_refresh"); assert!(!text.contains("alpha"), "old rows are gone"); assert_eq!(line, 1, "cursor re-seats on a data row after refresh"); press(&mut s, KeyCode::Enter); let (_, _, _, visited) = probe(&s); assert_eq!(visited.as_deref(), Some("D"), "the refreshed row visits"); } // --------------------------------------------------------------------------- // Generated-buffer immutability, Stage 1 // (docs/generated-buffer-immutability-framing.md §6, Stage 1) // --------------------------------------------------------------------------- /// The exact bytes `open_test_panel` renders. Asserted by value, not by /// `is_empty()`: "the panel is not empty" is the assertion shape the /// framing's §0.1 shows passing with the bug live on other families. const PANEL_TEXT: &str = "3 items RET visit q quit\nalpha\nbeta\ngamma"; /// Criterion 1 [`main`] --- `C-/` cannot empty a listview panel. /// /// Driven by a real chord through `dispatch_key`, because listview /// rebinds **no** undo chord (`grep -n 'C-/\|C-_\|C-x u\|undo' /// builtin/runtime/listview.lua` is empty), so this is the whole /// distance from a keystroke to an empty panel. /// /// *Bite:* measured on the pre-image --- `"H\nrow-one\nrow-two"` -> `""`. /// `scripts/bite githubsucks/main builtin/runtime/listview.lua` falsifies /// it: the panel's own render pushes a poppable undo entry, and /// `Buffer::undo` reaches the rope through `ensure_writable` without ever /// consulting the intercept chain. #[test] fn s1_1_the_undo_chord_cannot_empty_a_listview_panel() { let mut s = EditorState::new(); open_test_panel(&mut s); assert_eq!(active_text(&s), PANEL_TEXT, "precondition: rendered"); ctrl(&mut s, '/'); assert_eq!( active_text(&s), PANEL_TEXT, "C-/ must leave the panel's content intact" ); } /// Criterion 2 [`main`] --- `M-x buffer.undo` cannot empty a listview /// panel, driven through the **real** minibuffer. /// /// Separate from criterion 1 on purpose: a fix that only rebound the /// chords would pass 1 and fail this. `compile.lua`'s own comment already /// concedes the point ("command/menu undo stays dispatchable"). /// /// *Bite:* same empty result on the pre-image. #[test] fn s1_2_m_x_buffer_undo_cannot_empty_a_listview_panel() { let mut s = EditorState::new(); open_test_panel(&mut s); m_x(&mut s, "buffer.undo"); assert_eq!( active_name(&s), "*test-panel*", "the minibuffer round trip must land back in the panel" ); assert_eq!( active_text(&s), PANEL_TEXT, "M-x buffer.undo must leave the panel's content intact" ); } /// Criterion 4 [fix-shape] --- the owner's own refresh still works after /// the lock, and the buffer is still locked afterwards. /// /// *Bite:* a naive `set_read_only(true)` at panel creation passes /// criteria 1-3 and fails here, because it refuses the refresh the panel /// exists for. That is the failure mode `Buffer::set_generated_contents` /// exists to prevent, and it is why the **pairing** is the primitive. /// The assertion is on the content `g` produced, not on the call not /// raising. #[test] fn s1_4_the_owners_refresh_still_works_after_the_lock() { let mut s = EditorState::new(); open_test_panel(&mut s); let panel = id_of(&s, "*test-panel*"); assert!( is_read_only(&s, panel), "precondition: the first render locked the rope" ); press(&mut s, KeyCode::Char('g')); let text = active_text(&s); assert!(text.contains("delta"), "g must re-render: {text:?}"); assert!( !text.contains("alpha"), "and replace the old rows: {text:?}" ); assert!( is_read_only(&s, panel), "and the panel must still be locked afterwards" ); } /// Criterion 5 [fix-shape] --- the named intercept survives adoption and /// is still the thing that refuses an edit whenever the rope does not. /// /// **Restated against the framing, which specified this criterion in a /// form the tree cannot reach.** §6 Stage 1 criterion 5 asks for an /// ordinary edit refused "by the INTERCEPT, not by the rope", asserted on /// the message text. Once the arc's own lock is installed that state is /// unreachable: `Buffer::apply_edit` (`src/buffer.rs:773`) and /// `Buffer::begin_edit` (`:725`) both call `ensure_writable()` as their /// FIRST statement, while the intercept chain runs later, inside /// `apply_edit_inner` (`:1072`). Measured here: a self-insert on an /// adopted panel now reports /// ``insert failed: buffer `*test-panel*` (id BufferId(n)) is read-only`` /// --- the rope's message --- and can never report the intercept's. /// /// So the criterion is driven at the one point where the two are /// distinguishable, which is also the state it is actually protecting: /// the rope lock lifted. That covers the real window between /// `pmacs.buffer.create` and the first render, and any future Rust-side /// lift. /// /// *Bite:* unchanged from the framing's --- an adopter that deletes the /// `add_intercept` call and relies on the rope alone passes criteria 1-4 /// and fails here, because with the lock lifted the `z` lands. #[test] fn s1_5_an_ordinary_edit_is_refused_by_the_named_intercept_not_only_the_rope() { let mut s = EditorState::new(); open_test_panel(&mut s); let panel = id_of(&s, "*test-panel*"); // With the lock ON, the rope answers first and the message is its // own. Pinned so the restatement above cannot rot silently. press(&mut s, KeyCode::Char('z')); assert!( status(&s).contains("(id BufferId("), "with the lock on, the ROPE refuses first; got {:?}", status(&s) ); set_read_only(&s, panel, false); press(&mut s, KeyCode::Char('z')); set_read_only(&s, panel, true); assert_eq!( active_text(&s), PANEL_TEXT, "the intercept must refuse the edit even with the rope writable" ); let st = status(&s); assert!( st.contains("listview.lua") && st.contains("*test-panel* is read-only"), "and refuse it by NAME, not with the rope's message; got {st:?}" ); } /// Criterion 6 [fix-shape] --- `set_round_trip_input` survives adoption, /// asserted so that only the round-trip mark can make it pass. /// /// `dispatch_idle_for` (`src/editor.rs:1126-1155`) returns `false` for /// **six** independent reasons, so `!dispatch_idle_for(..)` alone is /// satisfied by any of them. All three halves are required: /// /// * **(a)** the document-window premise (`!window.is_side()`), so a /// fixture that later displays the panel in a side window fails loudly /// rather than passing vacuously --- `tests/dired_acceptance.rs:975`'s /// shape; /// * **(b)** the gate itself while the panel is focused; /// * **(c)** the positive control --- switching the same window to a /// plain buffer must flip the gate back to `true`. A stuck minibuffer, /// a pending chord, an open menu or a live search would keep it `false` /// across the switch, so (c) failing is the signal that (b) passed for /// the wrong reason. /// /// *Bite:* delete the `set_round_trip_input` call in `listview.lua` and /// criteria 1-5 all still pass; only (b) fails. A daemon-side rope /// refusal does nothing for a replica's own mirror, which is why this is /// pinned through `dispatch_idle_for` rather than through `read_only`. #[test] fn s1_6_round_trip_input_survives_the_adoption() { let mut s = EditorState::new(); open_test_panel(&mut s); // (a) the premise. { let core = s.core.borrow(); let active = core.active_window_id(); assert!( !core.windows.get(&active).expect("live window").is_side(), "fixture premise: the panel is in a document window here" ); } // (b) the gate. assert!( !s.dispatch_idle_for(FrontendId::LOCAL), "a round-trip buffer must turn optimistic apply OFF" ); // (c) the positive control. exec( &s, "pmacs.window.switch_buffer(pmacs.buffer.create('*plain*'))", ); assert!( s.dispatch_idle_for(FrontendId::LOCAL), "and back ON for a plain buffer --- otherwise (b) passed for one \ of the other five reasons" ); } /// Criterion 9 [`main`] --- a foreign buffer that happens to share the /// panel's name is never adopted (Q#GB13). /// /// Both halves, because the second is what fails if adoption is merely /// made "safe" by skipping the render: the user's bytes survive **and** /// an ordinary edit to the user's buffer still lands. Adoption installed /// an erroring intercept whose handle it discarded, so the pre-image left /// the clobbered buffer permanently un-editable --- and this arc removes /// the `M-x buffer.undo` that was the only way back. /// /// *Bite:* measured on the pre-image --- `"my precious notes"` -> /// `"H\nr1"`, one buffer where there should be two, and the user's buffer /// left un-editable. `scripts/bite githubsucks/main /// builtin/runtime/listview.lua` falsifies it. #[test] fn s1_9_a_foreign_buffer_with_the_panels_name_is_never_adopted() { let mut s = EditorState::new(); exec( &s, "FOREIGN = pmacs.buffer.create('*test-panel*')\n\ FOREIGN:insert(0, 'my precious notes')", ); open_test_panel(&mut s); let foreign: String = eval(&s, "return FOREIGN:slice(0, FOREIGN:len())"); assert_eq!( foreign, "my precious notes", "the user's bytes must survive the panel opening" ); let landed: String = eval( &s, "FOREIGN:insert(0, 'still mine: ')\n\ return FOREIGN:slice(0, FOREIGN:len())", ); assert_eq!( landed, "still mine: my precious notes", "and an ordinary edit to it must still land" ); assert_eq!( active_name(&s), "*test-panel*<2>", "the panel opens under a disambiguated name" ); let names = buffer_names(&s); assert!( names.iter().any(|n| n == "*test-panel*") && names.iter().any(|n| n == "*test-panel*<2>"), "two buffers, not one: {names:?}" ); } /// Criterion 10 [fix-shape] --- exhausting the disambiguation limit /// raises rather than falling back to adoption, matching /// `dired.lua:493-503` and `terminal.lua:309-315`. /// /// *Bite:* an implementation that adopts once `<99>` is taken passes /// criterion 9 and fails here --- and it fails in the worst direction, /// because the buffer it would adopt is by construction one a user /// created. #[test] fn s1_10_the_disambiguation_limit_raises_rather_than_adopting() { let s = EditorState::new(); exec( &s, "MINE = pmacs.buffer.create('*test-panel*')\n\ MINE:insert(0, 'mine')\n\ for i = 2, 99 do pmacs.buffer.create(string.format('*test-panel*<%d>', i)) end", ); let (ok, err): (bool, String) = eval( &s, "local ok, err = pcall(pmacs.listview.open, { name = '*test-panel*', rows = {} })\n\ return ok, tostring(err)", ); assert!(!ok, "the open must raise, not adopt"); assert!( err.contains("no free variant remains"), "and say why; got {err:?}" ); let mine: String = eval(&s, "return MINE:slice(0, MINE:len())"); assert_eq!(mine, "mine", "and touch nothing"); } /// Criterion 11 [`main`] --- a **disambiguated** panel still answers /// `RET`, `g` and `q` (Q#GB18). /// /// This is the criterion that fails against Q#GB13 landed without /// Q#GB18: disambiguation alone leaves the old lookup reading /// `panels["*test-panel*<2>"]` for a record stored under /// `"*test-panel*"`, so all three commands return early. Every one of /// them fails **silently**, so the assertion is on the content each /// command produced, never on "it did not raise". #[test] fn s1_11_a_disambiguated_panel_still_answers_ret_g_and_q() { let mut s = EditorState::new(); exec( &s, "FOREIGN = pmacs.buffer.create('*test-panel*')\n\ ORIGIN = pmacs.buffer.create('*origin*')\n\ pmacs.window.switch_buffer(ORIGIN)", ); open_test_panel(&mut s); assert_eq!(active_name(&s), "*test-panel*<2>", "premise: disambiguated"); // g --- re-render from the data source. press(&mut s, KeyCode::Char('g')); let text = active_text(&s); assert!(text.contains("delta"), "g must re-render: {text:?}"); // RET --- fire on_visit for the row under the cursor. press(&mut s, KeyCode::Enter); let visited: Option = eval(&s, "return _G.VISITED"); assert_eq!( visited.as_deref(), Some("D"), "RET must visit the refreshed row" ); // q --- restore the buffer the panel was opened from. press(&mut s, KeyCode::Char('q')); assert_eq!( active_name(&s), "*origin*", "q must restore the previous buffer" ); } /// Criterion 12 [`main`] --- the `q`-target capture is not inverted /// (Q#GB18), which needs its own criterion because it fails **open** /// rather than closed. /// /// `listview.open`'s guard reads "capture the current buffer as the `q` /// target, but never another panel (chained panels would trap `q` in a /// loop)". When the lookup cannot recognise a disambiguated panel it /// returns nil, the guard reads "not a panel", and the panel is captured /// as the next panel's `q` target --- producing exactly the loop the /// guard exists to prevent. Criterion 11 passes with that bug live, /// because each command works in isolation; only the two-panel sequence /// shows it. /// /// *Bite:* restore the name-keyed `panels[d.name]` lookup while keeping /// the disambiguation and `q` lands back in `*test-panel*<2>`. #[test] fn s1_12_the_q_target_capture_is_not_inverted_across_two_panels() { let mut s = EditorState::new(); exec( &s, "FOREIGN = pmacs.buffer.create('*test-panel*')\n\ ORIGIN = pmacs.buffer.create('*origin*')\n\ pmacs.window.switch_buffer(ORIGIN)", ); open_test_panel(&mut s); assert_eq!(active_name(&s), "*test-panel*<2>", "premise: disambiguated"); exec( &s, "pmacs.listview.open { name = '*other-panel*', header = 'O', \ rows = { { text = 'x', item = 'X' } } }", ); assert_eq!(active_name(&s), "*other-panel*", "premise: second panel"); press(&mut s, KeyCode::Char('q')); assert_ne!( active_name(&s), "*test-panel*<2>", "q must never return into another panel --- the chained-panel loop" ); assert_eq!( active_name(&s), "*scratch*", "with no capturable previous buffer, q falls back to *scratch*" ); } /// Criterion 14 [structural] --- rides **alongside** 1-13, never instead: /// a structural comparison of two authorities does not catch a misrouted /// consumer, which is why 11 and 12 assert through `dispatch_key`. /// /// Three claims, each keyed to a decision: no `bypass_intercept` write /// survives in either Stage 1 adopter (§1.1's arithmetic is the /// reference, so the check is per non-comment line rather than a /// substring sweep that the explanatory comments would trip); /// `ensure_panel` contains no find-by-name adoption (Q#GB13); and every /// `panels[` subscript is an append, so none can be keyed by a name /// derived from `describe.buffer` (Q#GB18). #[test] fn s1_14_no_bypass_write_or_name_keyed_identity_remains() { const LISTVIEW: &str = include_str!("../builtin/runtime/listview.lua"); const DIRED: &str = include_str!("../builtin/runtime/dired.lua"); for (file, src) in [("listview.lua", LISTVIEW), ("dired.lua", DIRED)] { let writes: Vec<&str> = src .lines() .filter(|l| !l.trim_start().starts_with("--") && l.contains("bypass_intercept")) .collect(); assert!( writes.is_empty(), "{file} must contain no bypass_intercept write; found {writes:?}" ); assert!( src.contains("set_generated_contents"), "{file} must write through the authorized primitive" ); } assert!( !LISTVIEW.contains("find_buffer_by_name(name) or pmacs.buffer.create"), "ensure_panel must not adopt a same-named foreign buffer" ); let subscripts: Vec<&str> = LISTVIEW .lines() .map(str::trim) .filter(|line| !line.starts_with("--") && line.contains("panels[")) .filter(|line| !line.starts_with("panels[#panels + 1]")) .collect(); assert!( subscripts.is_empty(), "every `panels[` subscript must be an append; found {subscripts:?}" ); }