665 lines
24 KiB
Rust
665 lines
24 KiB
Rust
//! 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 <name> 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<T: mlua::FromLuaMulti>(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<String> {
|
|
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<String>) {
|
|
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<String> = 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:?}"
|
|
);
|
|
}
|