562 lines
20 KiB
Rust
562 lines
20 KiB
Rust
// tests/journey_acceptance.rs --- the golden product journey.
|
||
|
||
//! The first cross-subsystem acceptance suite (`COHERENCE.md` §19,
|
||
//! `docs/journey-stage1a-framing.md` §5).
|
||
//!
|
||
//! Every other suite in the tree pins one subsystem's contract. This one
|
||
//! pins that the subsystems form a usable whole, walking `COHERENCE.md`
|
||
//! §2's twelve-step journey. Stage 1a seeds it with the steps that are
|
||
//! real today — 2 (launch unconfigured), 3 (open a real project), and 5
|
||
//! (edit immediately). Steps 6–12 join as later stages make them real.
|
||
//!
|
||
//! **This file is a ratchet: stages add rows, none removes them.**
|
||
//!
|
||
//! Two disciplines it must keep:
|
||
//!
|
||
//! * **Drive the real entry point.** A directory arm with no production
|
||
//! caller passes every direct-call test, so step 3 goes through
|
||
//! `EditorState::open` — the same function `pmacs FILE` calls — and
|
||
//! not through `resolve_target_buffer`.
|
||
//! * **Pump to quiescence, never to a frame count.** Every listing is
|
||
//! worker-dispatched; `tick_async` resuming a coroutine in the frame
|
||
//! its result arrives does not bound when the worker finishes.
|
||
//!
|
||
//! Pins are labelled **N** (new behavior — must fail on full revert) or
|
||
//! **P** (preservation — legitimately green on the pre-image, falsified
|
||
//! by the named targeted mutation). See framing §6.0 for why the
|
||
//! distinction is load-bearing: an equivalence assertion between two
|
||
//! implementations that already agree proves nothing about structural
|
||
//! reuse.
|
||
|
||
use std::path::Path;
|
||
use std::time::{Duration, Instant};
|
||
|
||
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers};
|
||
use pmacs::editor::EditorState;
|
||
use pmacs::editor_core::normalize_buffer_path;
|
||
use pmacs::protocol::FrontendId;
|
||
use tempfile::TempDir;
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Harness
|
||
// ---------------------------------------------------------------------------
|
||
|
||
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()
|
||
}
|
||
|
||
/// Drive the async runtime to quiescence — no parked coroutine, no
|
||
/// pending worker job. The directory listing is invisible until this
|
||
/// returns, and how many frames it takes is not knowable in advance.
|
||
fn pump(s: &mut EditorState) {
|
||
let deadline = Instant::now() + Duration::from_secs(10);
|
||
loop {
|
||
let idle: bool = eval(
|
||
s,
|
||
"return pmacs._async.parked_count() == 0 and pmacs._async.pending_count() == 0",
|
||
);
|
||
if idle {
|
||
return;
|
||
}
|
||
assert!(Instant::now() < deadline, "async pump deadline exceeded");
|
||
s.tick_async();
|
||
}
|
||
}
|
||
|
||
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_char(s: &mut EditorState, c: char) {
|
||
s.dispatch_key(FrontendId::LOCAL, key(KeyCode::Char(c), KeyModifiers::NONE));
|
||
}
|
||
|
||
/// The 0-based line an entry renders on, found by its trailing name
|
||
/// column -- the same shape `dired_acceptance` uses.
|
||
fn line_of(s: &EditorState, name: &str) -> usize {
|
||
let text = active_text(s);
|
||
for (index, line) in text.lines().enumerate() {
|
||
if line.trim_end().ends_with(name) {
|
||
return index;
|
||
}
|
||
}
|
||
panic!("no listing line for {name:?} in:\n{text}");
|
||
}
|
||
|
||
/// A project a journey can plausibly be run against.
|
||
fn project() -> TempDir {
|
||
let td = tempfile::tempdir().expect("tempdir");
|
||
std::fs::write(td.path().join("alpha.txt"), b"alpha\n").expect("write alpha");
|
||
std::fs::write(td.path().join("beta.txt"), b"beta\n").expect("write beta");
|
||
td
|
||
}
|
||
|
||
fn canon(path: &Path) -> String {
|
||
normalize_buffer_path(path.to_path_buf())
|
||
.to_string_lossy()
|
||
.into_owned()
|
||
}
|
||
|
||
fn active_name(s: &EditorState) -> String {
|
||
eval(s, "return pmacs.window.buffer():name()")
|
||
}
|
||
|
||
fn active_text(s: &EditorState) -> String {
|
||
eval(
|
||
s,
|
||
"local b = pmacs.window.buffer()\nreturn b:slice(0, b:len())",
|
||
)
|
||
}
|
||
|
||
fn status(s: &EditorState) -> String {
|
||
s.core.borrow().status.clone()
|
||
}
|
||
|
||
fn buffer_count(s: &EditorState) -> usize {
|
||
s.core.borrow().registry.borrow().ids().len()
|
||
}
|
||
|
||
/// Open through the **real** startup entry point, as `pmacs PATH` does.
|
||
fn launch(path: &Path) -> EditorState {
|
||
let mut s = EditorState::open(path.to_path_buf()).expect("startup must not fail");
|
||
exec(&s, "pmacs.lsp.config = {}");
|
||
pump(&mut s);
|
||
s
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Step 2 — launch unconfigured
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// **N** — the editor starts with no configuration and no arguments.
|
||
#[test]
|
||
fn journey_step2_launches_unconfigured_into_scratch() {
|
||
let s = EditorState::new();
|
||
assert_eq!(active_name(&s), "*scratch*");
|
||
assert!(
|
||
status(&s).is_empty(),
|
||
"a clean launch reports no error; got {:?}",
|
||
status(&s)
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Step 3 — open a real project
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// **N1** — `pmacs .` opens the directory.
|
||
///
|
||
/// The headline of Stage 1a and of `COHERENCE.md` §2's "broken at step
|
||
/// 3" grade. Before the directory arm this construction returned
|
||
/// `Err(EISDIR)` and `main` exited 1.
|
||
#[test]
|
||
fn journey_step3_opening_a_directory_lists_it() {
|
||
let td = project();
|
||
let s = launch(td.path());
|
||
|
||
let name = active_name(&s);
|
||
assert_eq!(
|
||
name,
|
||
format!("*dired:{}*", canon(td.path())),
|
||
"the active buffer must be the directory's dired buffer"
|
||
);
|
||
let text = active_text(&s);
|
||
assert!(
|
||
text.contains("alpha.txt") && text.contains("beta.txt"),
|
||
"the listing must show the directory's entries; got {text:?}"
|
||
);
|
||
}
|
||
|
||
/// **N1b** — and it is a *successful* startup, not a rescued failure.
|
||
///
|
||
/// Guards the specific regression shape: an implementation that opened
|
||
/// dired but still left an error on the status line would look right in
|
||
/// the assertion above while `pmacs .` still printed a diagnostic.
|
||
#[test]
|
||
fn journey_step3_directory_startup_reports_no_error() {
|
||
let td = project();
|
||
let s = launch(td.path());
|
||
assert!(
|
||
!status(&s).contains("cannot open"),
|
||
"a successful directory open must not leave an error status; got {:?}",
|
||
status(&s)
|
||
);
|
||
}
|
||
|
||
/// **N3** — an unreadable directory reports and leaves the session
|
||
/// running, rather than failing startup.
|
||
#[cfg(target_os = "linux")]
|
||
#[test]
|
||
fn journey_step3_unreadable_directory_reports_without_failing_startup() {
|
||
use std::os::unix::fs::PermissionsExt;
|
||
let td = tempfile::tempdir().expect("tempdir");
|
||
let locked = td.path().join("locked");
|
||
std::fs::create_dir(&locked).expect("mkdir");
|
||
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000)).expect("chmod");
|
||
|
||
// Startup itself must succeed: the failure is the *listing*, which
|
||
// happens a tick later and belongs on the status line.
|
||
let s = launch(&locked);
|
||
assert!(
|
||
!status(&s).is_empty(),
|
||
"a failed listing must report through the status line"
|
||
);
|
||
assert!(
|
||
!active_name(&s).starts_with("*dired:"),
|
||
"a failed listing must leave no dired buffer behind"
|
||
);
|
||
|
||
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o700)).expect("restore");
|
||
}
|
||
|
||
/// **N9** — the resolver receives a canonical absolute path.
|
||
///
|
||
/// Falsified by dropping the normalization in
|
||
/// `ResolvedTarget::Directory`: nothing else normalizes on that arm,
|
||
/// because no buffer is created and `set_buffer_path` never runs.
|
||
#[test]
|
||
fn journey_directory_resolver_receives_a_canonical_path() {
|
||
let td = project();
|
||
let mut s = EditorState::new();
|
||
exec(&s, "pmacs.lsp.config = {}");
|
||
exec(
|
||
&s,
|
||
"seen = nil
|
||
pmacs.hook.add('path.open-directory', function(path) seen = path return false end)",
|
||
);
|
||
|
||
// A path with a redundant component, which only canonicalization removes.
|
||
let noisy = td.path().join("subdir").join("..");
|
||
std::fs::create_dir_all(td.path().join("subdir")).expect("mkdir");
|
||
s.open_directory_target(&noisy);
|
||
pump(&mut s);
|
||
|
||
let seen: String = eval(&s, "return seen");
|
||
assert_eq!(
|
||
seen,
|
||
canon(td.path()),
|
||
"the resolver must receive the canonical path, not the literal argument"
|
||
);
|
||
}
|
||
|
||
/// **N10** — with the handler cleared and nothing claiming, a directory
|
||
/// argument still starts successfully.
|
||
///
|
||
/// The regression path back to exit 1. Reachable only because the
|
||
/// fallback is a clearable slot rather than a builtin hook subscription.
|
||
#[test]
|
||
fn journey_unclaimed_directory_starts_successfully_with_a_status() {
|
||
let td = project();
|
||
let mut s = EditorState::new();
|
||
exec(&s, "pmacs.lsp.config = {}");
|
||
exec(&s, "pmacs.path.set_directory_handler(nil)");
|
||
|
||
let before = active_name(&s);
|
||
s.open_directory_target(td.path());
|
||
pump(&mut s);
|
||
|
||
assert_eq!(
|
||
active_name(&s),
|
||
before,
|
||
"with no handler the window keeps the buffer it had"
|
||
);
|
||
assert!(
|
||
status(&s).contains(&canon(td.path())),
|
||
"the status must name the directory nothing surfaced; got {:?}",
|
||
status(&s)
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// The resolver chain
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// **N7** — first claimant wins, through an ordinary user listener, and
|
||
/// a claim suppresses the fallback.
|
||
#[test]
|
||
fn journey_resolver_chain_is_first_claimant_wins() {
|
||
let td = project();
|
||
let mut s = EditorState::new();
|
||
exec(&s, "pmacs.lsp.config = {}");
|
||
exec(
|
||
&s,
|
||
"first, second, fallback_ran = false, false, false
|
||
pmacs.path.set_directory_handler(function() fallback_ran = true end)
|
||
pmacs.hook.add('path.open-directory', function() first = true return false end)
|
||
pmacs.hook.add('path.open-directory', function() second = true return false end)",
|
||
);
|
||
|
||
s.open_directory_target(td.path());
|
||
pump(&mut s);
|
||
|
||
assert!(eval::<bool>(&s, "return first"), "the first listener runs");
|
||
assert!(
|
||
!eval::<bool>(&s, "return second"),
|
||
"a claim stops the fan-out before the second listener"
|
||
);
|
||
assert!(
|
||
!eval::<bool>(&s, "return fallback_ran"),
|
||
"a claim suppresses the fallback"
|
||
);
|
||
}
|
||
|
||
/// **N8** — a raising listener suppresses the fallback *and* is
|
||
/// reported.
|
||
///
|
||
/// Falsified by running the fallback when `errors` is non-empty (i.e.
|
||
/// treating a raise as a decline), or by making a raise yield
|
||
/// `proceed = true`. NOT falsified by keying suppression on `proceed`
|
||
/// alone — that is already correct, since a raise and a claim both give
|
||
/// `proceed == false`.
|
||
#[test]
|
||
fn journey_a_raising_resolver_suppresses_the_fallback_and_reports() {
|
||
let td = project();
|
||
let mut s = EditorState::new();
|
||
exec(&s, "pmacs.lsp.config = {}");
|
||
exec(
|
||
&s,
|
||
"fallback_ran = false
|
||
pmacs.path.set_directory_handler(function() fallback_ran = true end)
|
||
pmacs.hook.add('path.open-directory', function() error('resolver exploded') end)",
|
||
);
|
||
|
||
s.open_directory_target(td.path());
|
||
pump(&mut s);
|
||
|
||
assert!(
|
||
!eval::<bool>(&s, "return fallback_ran"),
|
||
"a crashed resolver must not fall through to the default surface"
|
||
);
|
||
assert!(
|
||
!status(&s).is_empty(),
|
||
"the failure must reach the status line, not only *errors*"
|
||
);
|
||
let errors: String = eval(
|
||
&s,
|
||
"for _, id in ipairs(pmacs.buffer.list()) do
|
||
local ok, d = pcall(pmacs.describe.buffer, id)
|
||
if ok and d and d.name == '*errors*' then
|
||
return id:slice(0, id:len())
|
||
end
|
||
end
|
||
return ''",
|
||
);
|
||
assert!(
|
||
errors.contains("resolver exploded"),
|
||
"the failure must also reach the *errors* buffer; got {errors:?}"
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Step 5 — edit immediately
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// **N11** — the journey's step-3-into-step-5 path, through the real
|
||
/// input path at every step: start on a directory, press `RET` on a
|
||
/// listed file, then type a character into it.
|
||
///
|
||
/// Rev 6 correction: this previously called `display_file` and
|
||
/// `buf:insert` directly, so it stayed green with dired's `RET` binding,
|
||
/// its entry dispatch, or the editor's self-insert path all broken —
|
||
/// which is most of what "the journey works" is supposed to mean. Both
|
||
/// gestures are now dispatched as keys.
|
||
///
|
||
/// Deliberately not a self-insert into the dired buffer, whose intercept
|
||
/// rejects every edit: asserting an edit lands there would contradict
|
||
/// the read-only contract rather than pin the journey.
|
||
#[test]
|
||
fn journey_step5_editing_a_file_reached_through_the_directory() {
|
||
let td = project();
|
||
let mut s = launch(td.path());
|
||
assert!(active_name(&s).starts_with("*dired:"));
|
||
|
||
// Seat on the entry, then VISIT it with the real key.
|
||
let line = line_of(&s, "alpha.txt");
|
||
exec(&s, &format!("pmacs.editor.move_to_line({line})"));
|
||
press(&mut s, KeyCode::Enter);
|
||
pump(&mut s);
|
||
|
||
assert_eq!(
|
||
active_name(&s),
|
||
td.path().join("alpha.txt").display().to_string(),
|
||
"RET on a listed file must visit it"
|
||
);
|
||
|
||
// And type into it with the real key.
|
||
type_char(&mut s, 'X');
|
||
let text = active_text(&s);
|
||
assert!(
|
||
text.starts_with('X'),
|
||
"a self-insert must land in the visited file's buffer; got {text:?}"
|
||
);
|
||
assert!(
|
||
buffer_count(&s) >= 2,
|
||
"the dired buffer and the visited file both exist"
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Preservation pins (P) — green on the pre-image; see the named mutation
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// **P4** — startup shows the file in the *active* window.
|
||
///
|
||
/// *Mutation:* replace `replace_active_buffer` with a bare
|
||
/// `install_buffer_in_window` into some other window in
|
||
/// `EditorState::open`.
|
||
///
|
||
/// **Note, found during implementation:** this does NOT assert that the
|
||
/// initial scratch buffer is destroyed, because it is not.
|
||
/// `replace_active_buffer`'s doc comment claims it drops "any old
|
||
/// scratch buffer if the active window's previous buffer has no other
|
||
/// windows referencing it", but all it does is call
|
||
/// `switch_active_buffer`, which reassigns the window's `buffer_id` and
|
||
/// never removes anything. The stale scratch survives in the registry
|
||
/// today, on `main`, unrelated to this stage — so asserting otherwise
|
||
/// would have pinned a guarantee the editor does not make and failed on
|
||
/// the pre-image for the wrong reason. What the unification must
|
||
/// preserve is which window shows the file, and that is what this pins.
|
||
#[test]
|
||
fn preservation_opening_a_file_shows_it_in_the_active_window() {
|
||
let td = project();
|
||
let target = td.path().join("alpha.txt");
|
||
let s = EditorState::open(target.clone()).expect("open");
|
||
|
||
// The displayed name is the argument as given (`path.display()`),
|
||
// which both implementations have always produced -- the *stored*
|
||
// path is what gets normalized, inside `set_buffer_path`.
|
||
assert_eq!(
|
||
active_name(&s),
|
||
target.display().to_string(),
|
||
"the file must be in the active window, not merely loaded"
|
||
);
|
||
let scratch_displayed: bool = eval(
|
||
&s,
|
||
"for _, id in ipairs(pmacs.buffer.list()) do
|
||
local ok, d = pcall(pmacs.describe.buffer, id)
|
||
if ok and d and d.name == '*scratch*' and pmacs.window.buffer() == id then
|
||
return true
|
||
end
|
||
end
|
||
return false",
|
||
);
|
||
assert!(
|
||
!scratch_displayed,
|
||
"no window may still be showing the startup scratch buffer"
|
||
);
|
||
}
|
||
|
||
/// **P5** — the `NotFound` arm survives the unification.
|
||
///
|
||
/// *Mutation:* delete the `NotFound` arm from `resolve_target_buffer`.
|
||
/// The arm most likely to be lost in a wholesale refactor, because its
|
||
/// failure mode is a hard error on a perfectly ordinary gesture.
|
||
#[test]
|
||
fn preservation_a_missing_path_becomes_a_new_file_buffer() {
|
||
let td = project();
|
||
let fresh = td.path().join("not-yet.txt");
|
||
let s = EditorState::open(fresh.clone()).expect("a missing path is not an error");
|
||
|
||
assert_eq!(status(&s), "[new file]");
|
||
let len: usize = eval(&s, "return pmacs.window.buffer():len()");
|
||
assert_eq!(len, 0, "a new-file buffer starts empty");
|
||
assert!(!fresh.exists(), "nothing is written until save");
|
||
}
|
||
|
||
/// **P8** — a startup failure names the file.
|
||
///
|
||
/// The message gained a `cannot open {path}: ` prefix in Stage 1a; the
|
||
/// *failure* is preserved, only its wording improved. Before, the bare
|
||
/// `io::Error` never named the path.
|
||
#[cfg(target_os = "linux")]
|
||
#[test]
|
||
fn preservation_an_unreadable_file_reports_with_its_path() {
|
||
use std::os::unix::fs::PermissionsExt;
|
||
let td = project();
|
||
let locked = td.path().join("locked.txt");
|
||
std::fs::write(&locked, b"secret\n").expect("write");
|
||
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000)).expect("chmod");
|
||
|
||
let rendered = match EditorState::open(locked.clone()) {
|
||
Ok(_) => panic!("an unreadable file must fail"),
|
||
Err(error) => error.to_string(),
|
||
};
|
||
|
||
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o600)).expect("restore");
|
||
|
||
assert!(
|
||
rendered.contains("cannot open"),
|
||
"the message must say what failed; got {rendered:?}"
|
||
);
|
||
assert!(
|
||
rendered.contains(&locked.display().to_string()),
|
||
"the message must name the file; got {rendered:?}"
|
||
);
|
||
}
|
||
|
||
// **P7 — removed in rev 6, not weakened.**
|
||
//
|
||
// Q#JR12 said a directory argument must suppress desktop restore, and
|
||
// rev 5 carried a pin for it. There is nothing to pin. `run` computes
|
||
// `had_file = file.is_some()` (`editor.rs:3152`) and a directory path is
|
||
// `Some` like any other, so the suppression is structural: no
|
||
// directory-specific branch exists that could get it wrong, and the
|
||
// named mutation ("pass false for `had_file` on the directory path")
|
||
// would require inventing the branch first.
|
||
//
|
||
// The rev 5 test also never armed desktop restore and hard-coded
|
||
// `had_file = true` after startup, so it asserted nothing about `run`'s
|
||
// decision and would have passed against any implementation. Keeping a
|
||
// green test that cannot fail is worse than having none: it reads as
|
||
// coverage. Q#JR12 is downgraded to an observation in the framing.
|
||
|
||
/// **P6** — `display_file` keeps its directory-is-an-error contract and
|
||
/// does not enter the resolver chain.
|
||
///
|
||
/// *Mutation:* route `display_file` into the directory resolver.
|
||
/// `find_file_accepting_a_directory_reports_instead_of_raising` in
|
||
/// `find_file_acceptance.rs` is the companion pin through find-file's
|
||
/// real accept path; this one pins the primitive and the window state.
|
||
#[test]
|
||
fn preservation_display_file_still_refuses_a_directory() {
|
||
let td = project();
|
||
let mut s = EditorState::open(td.path().join("alpha.txt")).expect("open");
|
||
exec(&s, "pmacs.lsp.config = {}");
|
||
let before_name = active_name(&s);
|
||
let before_count = buffer_count(&s);
|
||
|
||
let raised: bool = eval(
|
||
&s,
|
||
&format!(
|
||
"local ok = pcall(pmacs.window.display_file, {:?}) return not ok",
|
||
td.path().display().to_string()
|
||
),
|
||
);
|
||
pump(&mut s);
|
||
|
||
assert!(raised, "display_file on a directory must raise");
|
||
assert_eq!(
|
||
active_name(&s),
|
||
before_name,
|
||
"a refused display_file must not change the active buffer"
|
||
);
|
||
assert_eq!(
|
||
buffer_count(&s),
|
||
before_count,
|
||
"a refused display_file must not create a buffer"
|
||
);
|
||
}
|