pmacs/tests/discovery_acceptance.rs

475 lines
16 KiB
Rust

// tests/discovery_acceptance.rs --- P4 Stage 1, the discovery family.
//! `COHERENCE.md` §5 graded discoverability "substrate without surface".
//! These pins cover the surface:
//! `docs/discovery-stage1-command-family-framing.md` §4.
//!
//! **Every command is driven through the real M-x path**, stated once in
//! `run_from_palette` below. `pmacs.command.invoke_interactive` is *not*
//! M-x — it rotates the interactive-command boundary and calls the body;
//! it opens no palette. Journey Stage 1b-2 established this and Stage
//! 1b-3 re-established it, so it is encoded in a helper here rather than
//! left to each pin to remember.
//!
//! Six of the eleven canonical commands open a **second** prompt. A pin
//! that stops after the first RET has tested the palette, not the
//! command.
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers};
use pmacs::editor::EditorState;
use pmacs::protocol::FrontendId;
#[path = "common/iso.rs"]
mod iso;
fn exec(s: &EditorState, src: &str) {
s.lua_host.lua().load(src.to_owned()).exec().unwrap();
}
fn eval<T: mlua::FromLuaMulti>(s: &EditorState, src: &str) -> T {
s.lua_host.lua().load(src.to_owned()).eval().unwrap()
}
fn key(code: KeyCode, mods: KeyModifiers) -> KeyEvent {
KeyEvent {
code,
modifiers: mods,
kind: KeyEventKind::Press,
state: KeyEventState::NONE,
}
}
fn press(s: &mut EditorState, code: KeyCode) {
s.dispatch_key(FrontendId::LOCAL, key(code, KeyModifiers::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 minibuffer_active(s: &EditorState) -> bool {
eval(s, "return pmacs.minibuffer.is_active()")
}
fn named_text(s: &EditorState, name: &str) -> String {
eval(
s,
&format!(
r#"
for _, id in ipairs(pmacs.buffer.list()) do
if pmacs.describe.buffer(id).name == {name:?} then
return id:slice(0, id:len())
end
end
return ""
"#
),
)
}
fn help_text(s: &EditorState) -> String {
named_text(s, "*help*")
}
/// Drive the **real** M-x path: dispatch `M-x`, type the command name,
/// assert the palette selected exactly that command *before* RET — the
/// only moment it is observable, since `accept()` does `session.take()`
/// and a selected candidate shadows typed text — then accept.
///
/// `second` is the argument for the six commands that open another
/// prompt; `None` for the five that do not.
fn run_from_palette(s: &mut EditorState, command: &str, second: Option<&str>) {
s.dispatch_key(
FrontendId::LOCAL,
key(KeyCode::Char('x'), KeyModifiers::ALT),
);
assert!(minibuffer_active(s), "M-x must open the palette");
type_str(s, command);
assert_eq!(
eval::<Option<String>>(s, "return pmacs.minibuffer.selected()").as_deref(),
Some(command),
"the palette must have {command} selected; a different candidate \
would run a different command"
);
press(s, KeyCode::Enter);
if let Some(arg) = second {
assert!(
minibuffer_active(s),
"{command} takes an argument and must open a second prompt"
);
exec(s, &format!("pmacs.minibuffer.set_contents({arg:?})"));
press(s, KeyCode::Enter);
} else {
assert!(
!minibuffer_active(s),
"{command} takes no argument; a second prompt means the census is wrong"
);
}
}
/// Constructed with **isolated bootstrap roots**, never ambiently.
///
/// An integration test is compiled without `cfg(test)`, so a raw
/// `EditorState::new()` reads the developer's real `init.lua` and writes
/// bundled packages into their real data root. The adoption ratchet in
/// `ambient_isolation_acceptance` caught this suite the moment the
/// isolation lane merged — which is the ratchet doing its job against
/// brand-new code, so this file is migrated rather than allowlisted.
fn editor() -> EditorState {
EditorState::new_with_roots(&iso::roots())
}
/// The eleven canonical commands and the argument each needs, if any.
/// **Six take one** — `describe-command` is easy to forget, because it
/// joined the family by rename rather than by being new.
fn family() -> Vec<(&'static str, Option<&'static str>)> {
vec![
("help.describe-command", Some("help.list-commands")),
("help.describe-setting", None), // supplied per-test: needs a real setting
("help.describe-key", Some("C-x C-f")),
("help.describe-mode", None),
("help.describe-buffer", None),
("help.describe-hook", Some("buffer.after-load")),
("help.where-is", Some("help.list-commands")),
("help.list-commands", None),
("help.list-keybindings", None),
("help.list-settings", None),
("help.apropos", Some("compile")),
]
}
// ---------------------------------------------------------------------------
// The family runs
// ---------------------------------------------------------------------------
/// **N (acceptance 1)** — every canonical command runs from M-x and
/// renders content, including the second prompt where it takes one.
#[test]
fn d1_every_command_runs_from_the_palette_and_renders() {
for (name, arg) in family() {
let mut s = editor();
// `describe-setting` needs a setting that exists; take the first.
let arg = if name == "help.describe-setting" {
Some(eval::<String>(&s, "return pmacs.config.list()[1].name"))
} else {
arg.map(str::to_owned)
};
run_from_palette(&mut s, name, arg.as_deref());
let text = help_text(&s);
assert!(
!text.is_empty(),
"{name} must render content into *help*; got empty"
);
}
}
/// **N (acceptance 2)** — `where-is` agrees with the keymap.
///
/// Falsified by rendering a static string.
#[test]
fn d2_where_is_reports_the_real_binding() {
let mut s = editor();
exec(
&s,
"pmacs.command.define { name = 'test.whereis-probe',
description = 'probe', fn = function() end }
pmacs.keymap.bind { scope = 'global', sequence = 'C-c Q',
command = 'test.whereis-probe' }",
);
run_from_palette(&mut s, "help.where-is", Some("test.whereis-probe"));
let text = help_text(&s);
assert!(
text.contains("C-c Q"),
"where-is must report the chord actually bound; got:\n{text}"
);
}
/// **N (acceptance 3)** — `list-keybindings` covers every binding
/// `keymap.list()` reports. A property over the data, not a fixed list.
#[test]
fn d3_list_keybindings_covers_every_binding() {
let mut s = editor();
let sequences: Vec<String> = eval(
&s,
"local out = {}
for _, r in ipairs(pmacs.keymap.list()) do out[#out+1] = r.sequence end
return out",
);
assert!(
!sequences.is_empty(),
"precondition: the keymap must be non-empty or this loop is vacuous"
);
run_from_palette(&mut s, "help.list-keybindings", None);
let text = help_text(&s);
for seq in sequences {
assert!(
text.contains(&seq),
"list-keybindings omits {seq:?}; got:\n{text}"
);
}
}
// ---------------------------------------------------------------------------
// apropos — substring, not fuzzy
// ---------------------------------------------------------------------------
/// **N (acceptance 4)** — apropos matches descriptions, not only names,
/// **and does so by substring**.
///
/// The negative half is the one that pins Q#D3. A bare "a subsequence
/// finds nothing" assertion would pass as an ordinary no-match; this
/// registers a fixture whose description contains `qzjx` **only** as the
/// non-contiguous sequence `q z j x`, and first proves no registered
/// command contains `qzjx` as a substring. A fuzzy implementation finds
/// the fixture, so the pin fails under fuzzy rather than passing.
#[test]
fn d4_apropos_matches_descriptions_by_substring_not_subsequence() {
let mut s = editor();
exec(
&s,
"pmacs.command.define { name = 'test.apropos-description-probe',
description = 'zzyzx marker for the description-search pin',
fn = function() end }
pmacs.command.define { name = 'test.apropos-subsequence-fixture',
description = 'q z j x letters spaced apart on purpose',
fn = function() end }",
);
// Positive: a word in exactly one DESCRIPTION and no NAME.
let name_hits: i64 = eval(
&s,
"local n = 0
for _, c in ipairs(pmacs.command.list()) do
if c:lower():find('zzyzx', 1, true) then n = n + 1 end
end
return n",
);
assert_eq!(name_hits, 0, "precondition: 'zzyzx' is in no command NAME");
run_from_palette(&mut s, "help.apropos", Some("zzyzx"));
assert!(
help_text(&s).contains("test.apropos-description-probe"),
"apropos must search descriptions, not only names; got:\n{}",
help_text(&s)
);
// Negative, discriminating: `qzjx` is a subsequence of the fixture's
// description but a substring of nothing.
let substring_hits: i64 = eval(
&s,
"local n = 0
for _, c in ipairs(pmacs.command.list()) do
local d = pmacs.describe.command(c)
local desc = (d and d.description) or ''
if c:lower():find('qzjx', 1, true) or desc:lower():find('qzjx', 1, true) then
n = n + 1
end
end
return n",
);
assert_eq!(
substring_hits, 0,
"precondition: 'qzjx' must be a substring of nothing, or the \
negative below proves nothing"
);
let mut s2 = editor();
exec(
&s2,
"pmacs.command.define { name = 'test.apropos-subsequence-fixture',
description = 'q z j x letters spaced apart on purpose',
fn = function() end }",
);
run_from_palette(&mut s2, "help.apropos", Some("qzjx"));
assert!(
!help_text(&s2).contains("test.apropos-subsequence-fixture"),
"substring matching must NOT find a subsequence — a fuzzy \
implementation finds the fixture here; got:\n{}",
help_text(&s2)
);
}
// ---------------------------------------------------------------------------
// describe-setting completion
// ---------------------------------------------------------------------------
/// **N (acceptance 5)** — completion assists, and a non-matching typo
/// still reaches the existing error path.
///
/// Both halves, because §3.2 has two outcomes and rev 1 asserted only a
/// third that does not exist ("a typo cannot reach `on_accept`").
#[test]
fn d5_describe_setting_completes_and_a_typo_still_errors() {
let mut s = editor();
let first: String = eval(&s, "return pmacs.config.list()[1].name");
// (a) typing a real setting's full name selects it.
s.dispatch_key(
FrontendId::LOCAL,
key(KeyCode::Char('x'), KeyModifiers::ALT),
);
type_str(&mut s, "help.describe-setting");
press(&mut s, KeyCode::Enter);
assert!(minibuffer_active(&s), "the setting prompt must open");
exec(&s, &format!("pmacs.minibuffer.set_contents({first:?})"));
assert_eq!(
eval::<Option<String>>(&s, "return pmacs.minibuffer.selected()").as_deref(),
Some(first.as_str()),
"a real setting name must be the selected candidate"
);
press(&mut s, KeyCode::Enter);
assert!(
help_text(&s).contains(&first),
"accepting a completed setting describes it"
);
// (b) a name matching nothing still reaches `on_accept` and errors —
// completion is assistance, not validation.
let mut s2 = editor();
run_from_palette(
&mut s2,
"help.describe-setting",
Some("qqzz-no-such-setting"),
);
assert!(
s2.core.borrow().status.contains("no such setting"),
"a non-matching typo reaches the existing error path; status: {:?}",
s2.core.borrow().status
);
}
// ---------------------------------------------------------------------------
// The index
// ---------------------------------------------------------------------------
/// **N (acceptance 6)** — `M-x help` lists the family, as a property.
///
/// Targeted mutation: adding a twelfth canonical command without
/// indexing it.
#[test]
fn d6_the_help_index_lists_every_family_command() {
let mut s = editor();
let family: Vec<String> = eval(&s, "return pmacs.help.family");
assert_eq!(
family.len(),
11,
"the canonical family is eleven commands; update the index and \
this pin together"
);
run_from_palette(&mut s, "help", None);
let text = help_text(&s);
for name in family {
assert!(text.contains(&name), "the index omits {name}; got:\n{text}");
}
}
// ---------------------------------------------------------------------------
// Preservation
// ---------------------------------------------------------------------------
/// **P (acceptance 7)** — every command's `*help*` write goes through
/// `_show_help`.
///
/// Pins §3.4's actual claim: one owner for `*help*` writes. Not a
/// one-site migration claim — `src/help.rs` has no renderer for
/// settings, lists or apropos.
#[test]
fn d7_preservation_every_render_goes_through_the_one_seam() {
let mut s = editor();
exec(
&s,
"_seam_calls = 0
local real = pmacs.editor._show_help
pmacs.editor._show_help = function(text)
_seam_calls = _seam_calls + 1
return real(text)
end",
);
for (name, arg) in family() {
let arg = if name == "help.describe-setting" {
Some(eval::<String>(&s, "return pmacs.config.list()[1].name"))
} else {
arg.map(str::to_owned)
};
run_from_palette(&mut s, name, arg.as_deref());
}
assert_eq!(
eval::<i64>(&s, "return _seam_calls"),
11,
"all eleven commands must render through _show_help; a command \
writing its own buffer would not be counted"
);
}
/// **P (acceptance 8)** — the old names still work, as forwarders.
///
/// Targeted mutation: dropping the forwarders after the rename, which is
/// the failure a user with muscle memory hits first.
#[test]
fn d8_preservation_the_old_names_forward() {
let mut s = editor();
run_from_palette(
&mut s,
"editor.describe-command",
Some("help.list-commands"),
);
let forwarded = help_text(&s);
assert!(
forwarded.contains("help.list-commands"),
"editor.describe-command must still describe the command it is \
given; got:\n{forwarded}"
);
let mut s2 = editor();
run_from_palette(&mut s2, "help.describe-command", Some("help.list-commands"));
assert_eq!(
forwarded,
help_text(&s2),
"the forwarder must render the same subject as its target"
);
}
/// **P (acceptance 8, cont.)** — the untouched list commands still work.
#[test]
fn d8b_preservation_list_buffers_and_workers_are_untouched() {
let s = editor();
for name in ["editor.list-buffers", "editor.list-workers"] {
assert!(
eval::<bool>(&s, &format!("return pmacs.command.exists({name:?})")),
"{name} must still be registered"
);
}
}
/// **P (acceptance 9)** — no command's predicate is evaluated.
///
/// Driven through the real palette, not `invoke_interactive` directly:
/// otherwise it would pass even if M-x itself grew predicate filtering.
/// A stage that starts evaluating predicates must change this pin
/// knowingly.
#[test]
fn d9_preservation_a_raising_predicate_does_not_block_a_command() {
let mut s = editor();
exec(
&s,
"_ran = false
pmacs.command.define {
name = 'test.predicate-probe',
description = 'probe whose predicate raises',
predicate = function() error('predicate evaluated') end,
fn = function() _ran = true end,
}",
);
run_from_palette(&mut s, "test.predicate-probe", None);
assert!(
eval::<bool>(&s, "return _ran"),
"the command must run: predicates are stored and exposed but \
never evaluated (framing §2.4)"
);
}