pmacs/tests/journey_acceptance.rs

1745 lines
62 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 612 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.
//!
//! Two P pins here — P1 and P2 — *also* fail on full revert, since
//! `commit_to` does not exist on the pre-image. They are labelled P
//! because their discriminating falsifier is the named mutation: a
//! revert-only check cannot distinguish "validates" from "validates in
//! time", which is their entire claim. Each says so at its own site.
use std::path::Path;
use std::time::{Duration, Instant};
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers};
use pmacs::buffer::BufferId;
use pmacs::editor::EditorState;
use pmacs::editor_core::normalize_buffer_path;
use pmacs::protocol::FrontendId;
use pmacs::window::{FrontendView, Layout, Window, WindowId};
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()
}
/// The buffer a window currently shows, or `None` if it is not live.
fn buffer_in(s: &EditorState, window: WindowId) -> Option<BufferId> {
s.core.borrow().windows.get(&window).map(|w| w.buffer_id)
}
/// The window `LOCAL` currently has selected.
fn local_window(s: &EditorState) -> WindowId {
s.core
.borrow()
.views
.get(&FrontendId::LOCAL)
.expect("LOCAL view")
.active
}
/// Register a second frontend with its own single-window layout,
/// mirroring `build_fresh_frontend_view` (the same helper shape
/// `bottom_panel_stage1_acceptance` uses).
fn attach_frontend(s: &EditorState, fid: FrontendId) -> WindowId {
let mut core = s.core.borrow_mut();
let buffer_id = core.active_buffer_id();
let text_view = {
let reg = core.registry.borrow();
pmacs::text_view::TextView::new(reg.get(buffer_id).expect("buffer"))
};
let win = WindowId::next();
core.windows
.insert(win, Window::new(win, buffer_id, text_view));
core.register_frontend_view(
fid,
FrontendView {
layout: Layout::single(win),
active: win,
fold_projection: true,
panel_capable: true,
frame_geometry: None,
panel_hidden: false,
},
);
win
}
/// Drive the **real** chain far enough to obtain a genuine destination
/// and leave it in the Lua global `dest`.
///
/// The listener claims (returns `false`), so nothing is committed and no
/// fallback runs: what lands in `dest` is exactly the userdata dired
/// would have received, produced by the production capture rather than
/// fabricated. Nothing in the test suite can construct one — that is
/// N6b's whole subject.
fn capture_dest(s: &mut EditorState, dir: &Path) {
exec(
s,
"dest = nil
pmacs.hook.add('path.open-directory', function(_, d) dest = d return false end)",
);
s.open_directory_target(dir);
pump(s);
assert!(
eval::<bool>(s, "return dest ~= nil"),
"the chain must hand listeners a destination"
);
}
/// 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:?}"
);
}
// ---------------------------------------------------------------------------
// The destination commit (`pmacs.window.commit_to`)
// ---------------------------------------------------------------------------
//
// The substrate half of Stage 1a. A directory listing settles a tick or
// more after the request, by which time the ambient frontend, selected
// window, and active buffer may all name something else — so the whole
// post-await commit runs against a destination captured at request time.
//
// `LOCAL` is the requesting frontend throughout, because
// `open_directory_target` is the local-startup seam; the daemon's
// non-`LOCAL` capture is pinned in `src/daemon.rs`, where the production
// caller lives. What varies here is what the *ambient* frontend is doing
// while the commit runs, which is exactly the misrouting the scope
// exists to prevent.
/// The frontend that competes for ambient authority in these tests.
const COMPETITOR: FrontendId = FrontendId(7);
/// **N4** — the commit lands in the *requesting* frontend's window even
/// though another frontend is the one dispatching.
///
/// The blocker's positive half. Falsified by reverting `commit_to` to an
/// ambient display: the file then appears in the competitor's window.
#[test]
fn commit_to_delivers_to_the_requesting_frontend_not_the_ambient_one() {
let td = project();
let mut s = EditorState::new();
exec(&s, "pmacs.lsp.config = {}");
capture_dest(&mut s, td.path());
let local_win = local_window(&s);
let other_win = attach_frontend(&s, COMPETITOR);
let other_before = buffer_in(&s, other_win);
// The competitor becomes the dispatching frontend while the work is
// "in flight" — the state a worker completion actually returns to.
s.core.borrow_mut().active_frontend = COMPETITOR;
let alpha = td.path().join("alpha.txt").display().to_string();
exec(
&s,
&format!(
"assert(pmacs.window.commit_to(dest, function()
pmacs.window.display_file({alpha:?})
end))"
),
);
assert_eq!(
buffer_in(&s, other_win),
other_before,
"the competing frontend's window must be untouched"
);
s.core.borrow_mut().active_frontend = FrontendId::LOCAL;
assert_eq!(
active_name(&s),
alpha,
"the commit must land in the requesting frontend's captured window"
);
assert_eq!(
local_window(&s),
local_win,
"and in that window, not a new one"
);
}
/// **N4b** — the scope beats an *interactive origin*, not merely the
/// ambient frontend.
///
/// Found by bite-testing N4: with the `ScopedFrontend` arm deleted from
/// `acting_frontend`, N4 still passed, because `ScopedFrontend::enter`
/// also swaps `core.active_frontend` and the ambient fallback then
/// answers correctly on its own. The arm is load-bearing in exactly one
/// situation — a commit reached from inside an interactive command,
/// where the origin sits *between* the override and the ambient value
/// and would otherwise win. `acting_frontend`'s comment claims that
/// ordering; nothing pinned it.
///
/// Driven through `dispatch_key`, because the interactive origin is
/// established by dispatch and by nothing else — `invoke_interactive`
/// requires a context rather than creating one.
///
/// Falsified by deleting the `ScopedFrontend` arm from
/// `acting_frontend`, or by reordering it after the interactive origin.
#[test]
fn commit_to_outranks_an_interactive_origin() {
let td = project();
let mut s = EditorState::new();
exec(&s, "pmacs.lsp.config = {}");
capture_dest(&mut s, td.path());
let local_win = local_window(&s);
let other_win = attach_frontend(&s, COMPETITOR);
let other_before = buffer_in(&s, other_win);
let alpha = td.path().join("alpha.txt").display().to_string();
exec(
&s,
&format!(
"pmacs.command.define {{
name = 'test.journey-commit',
description = 'commit to a captured destination from inside a command',
fn = function()
committed = pmacs.window.commit_to(dest, function()
pmacs.window.display_file({alpha:?})
end)
end,
}}
pmacs.keymap.bind {{ scope = 'global', sequence = 'C-c j',
command = 'test.journey-commit' }}"
),
);
// The COMPETITOR runs the command, so ITS id is the interactive
// origin for the whole invocation.
s.dispatch_key(COMPETITOR, key(KeyCode::Char('c'), KeyModifiers::CONTROL));
s.dispatch_key(COMPETITOR, key(KeyCode::Char('j'), KeyModifiers::NONE));
assert!(
eval::<bool>(&s, "return committed"),
"the commit must be accepted"
);
assert_eq!(
buffer_in(&s, other_win),
other_before,
"the invoking frontend's own window must be untouched"
);
s.core.borrow_mut().active_frontend = FrontendId::LOCAL;
assert_eq!(
active_name(&s),
alpha,
"the commit must land in the captured destination, not the \
interactive origin's window"
);
assert_eq!(local_window(&s), local_win);
}
/// **N4c** — the commit lands in the *captured window*, not merely in
/// the captured frontend's currently selected one.
///
/// Review finding on PR #182. Every other routing pin here varies
/// frontend identity; none varied the selected window *within* one
/// frontend, and dired's commit still ended in `switch_buffer`, which
/// targets whatever window the scoped frontend has active. The preflight
/// cannot catch this — the captured window is still live and still holds
/// its captured buffer — so a split that took focus while `read_dir` was
/// pending got the listing, and `prev` was captured from it too.
///
/// Both halves are asserted: where the listing lands, and where `q`
/// goes. Falsified by restoring `pmacs.window.switch_buffer` in dired's
/// `display`, or by reading `prev` from the ambient window.
#[test]
fn a_background_open_uses_the_captured_window_not_the_selected_one() {
let td = project();
let mut s = EditorState::new();
exec(&s, "pmacs.lsp.config = {}");
capture_dest(&mut s, td.path());
let target = local_window(&s);
let origin = buffer_in(&s, target).expect("the captured window's buffer");
// Split, move focus to the OTHER window, and give it a buffer of its
// own. The captured window is untouched, so every preflight check
// still passes -- which is exactly why this needs its own pin.
exec(
&s,
"local captured = dest:window()
pmacs.window.split_horizontal()
while pmacs.window.current() == captured do pmacs.window.focus_next() end
pmacs.window.switch_buffer(pmacs.buffer.create('*elsewhere*'))",
);
let elsewhere = local_window(&s);
assert_ne!(elsewhere, target, "focus must have moved to another window");
let elsewhere_buffer = buffer_in(&s, elsewhere);
// dired's real handler path, with the captured destination.
exec(
&s,
&format!(
"pmacs.async(function()
pmacs.dired.open({:?}, {{ dest = dest }})
end)",
canon(td.path())
),
);
pump(&mut s);
assert_eq!(
buffer_in(&s, elsewhere),
elsewhere_buffer,
"the window that took focus mid-listing must be untouched"
);
assert_eq!(
local_window(&s),
target,
"the commit must select the captured window"
);
assert!(
active_name(&s).starts_with("*dired:"),
"and the listing must be in it; got {:?}",
active_name(&s)
);
// `prev` came from the captured window too, not from `*elsewhere*`.
type_char(&mut s, 'q');
assert_eq!(
buffer_in(&s, target),
Some(origin),
"`q` must return to the buffer the CAPTURED window showed"
);
}
/// **N6a** — the scope is restored when the callback returns normally.
///
/// Falsified by dropping the guard's restore, or by never swapping
/// `core.active_frontend` in the first place (then `inside` reads the
/// competitor and the assertion fails from the other direction).
#[test]
fn commit_to_scopes_and_restores_on_a_normal_return() {
let td = project();
let mut s = EditorState::new();
exec(&s, "pmacs.lsp.config = {}");
capture_dest(&mut s, td.path());
attach_frontend(&s, COMPETITOR);
s.core.borrow_mut().active_frontend = COMPETITOR;
exec(
&s,
"inside, scoped = nil, nil
assert(pmacs.window.commit_to(dest, function()
inside = pmacs.frontend.id()
scoped = pmacs._async._in_commit_scope()
end))",
);
assert_eq!(
eval::<i64>(&s, "return inside"),
i64::try_from(FrontendId::LOCAL.0).expect("frontend id"),
"inside the commit the acting frontend is the requesting one"
);
assert!(
eval::<bool>(&s, "return scoped"),
"and the commit-scope flag is set while the callback runs"
);
assert_eq!(
s.core.borrow().active_frontend,
COMPETITOR,
"the ambient frontend must be restored on return"
);
assert!(
!eval::<bool>(&s, "return pmacs._async._in_commit_scope()"),
"and the commit-scope flag cleared"
);
assert_eq!(
eval::<i64>(&s, "return pmacs.frontend.id()"),
i64::try_from(COMPETITOR.0).expect("frontend id"),
"the Lua-visible frontend must be restored too"
);
}
/// **N6b (part of N6)** — a raising callback still restores.
///
/// The path that makes the guard RAII rather than a pair of statements:
/// `commit_to` captures the call's result and lets the guard drop before
/// propagating it. Falsified by `?`-propagating the callback's error
/// through the scope, or by restoring on the success path only.
#[test]
fn commit_to_restores_when_the_callback_raises() {
let td = project();
let mut s = EditorState::new();
exec(&s, "pmacs.lsp.config = {}");
capture_dest(&mut s, td.path());
attach_frontend(&s, COMPETITOR);
s.core.borrow_mut().active_frontend = COMPETITOR;
exec(
&s,
"local ok, err = pcall(pmacs.window.commit_to, dest, function()
error('commit exploded')
end)
raised = (not ok) and tostring(err) or '<no raise>'",
);
assert!(
eval::<String>(&s, "return raised").contains("commit exploded"),
"the callback's error must propagate"
);
assert_eq!(
s.core.borrow().active_frontend,
COMPETITOR,
"a raising callback must still restore the ambient frontend"
);
assert!(
!eval::<bool>(&s, "return pmacs._async._in_commit_scope()"),
"and must still clear the commit-scope flag"
);
}
/// **N6c (part of N6)** — awaiting inside a commit is refused, the
/// refusal names the rule, and the scope is restored anyway.
///
/// A yield would restore the scope while the coroutine is still parked,
/// so the rest of the commit would resume ambient — silently
/// reintroducing exactly the misrouting N4 pins against. Driven inside
/// `pmacs.async`, which is where a real await lives.
///
/// Falsified by dropping the `_in_commit_scope` check from
/// `Handle:await`: the await then succeeds and `refusal` reads
/// `<no raise>`.
#[test]
fn commit_to_refuses_an_await_and_restores() {
let td = project();
let mut s = EditorState::new();
exec(&s, "pmacs.lsp.config = {}");
capture_dest(&mut s, td.path());
attach_frontend(&s, COMPETITOR);
s.core.borrow_mut().active_frontend = COMPETITOR;
exec(
&s,
&format!(
"refusal = nil
pmacs.async(function()
local handle = pmacs.fs.read_dir({:?})
local ok, err = pcall(pmacs.window.commit_to, dest, function()
return handle:await()
end)
refusal = (not ok) and tostring(err) or '<no raise>'
-- Drain it OUTSIDE the commit, which is where the refusal
-- says the await belongs -- and which also settles the job
-- so the pump can reach quiescence.
handle:await()
end)",
td.path().display().to_string()
),
);
pump(&mut s);
let refusal: String = eval(&s, "return refusal");
assert!(
refusal.contains("cannot await inside") && refusal.contains("commit_to"),
"the refusal must name the rule it enforces; got {refusal:?}"
);
assert_eq!(
s.core.borrow().active_frontend,
COMPETITOR,
"a refused await must still restore the ambient frontend"
);
assert!(
!eval::<bool>(&s, "return pmacs._async._in_commit_scope()"),
"and must still clear the commit-scope flag"
);
}
/// **N6b** — a forged destination is rejected, and the callback never
/// runs.
///
/// A plausible `{frontend, window, buffer}` table is what any Lua could
/// fabricate. Falsified by accepting a table, or by borrowing the
/// userdata after invoking the callback.
#[test]
fn commit_to_refuses_a_forged_destination() {
let td = project();
let mut s = EditorState::new();
exec(&s, "pmacs.lsp.config = {}");
capture_dest(&mut s, td.path());
let win = eval::<i64>(&s, "return dest:window()");
exec(
&s,
&format!(
"ran = false
local ok, err = pcall(pmacs.window.commit_to,
{{ frontend = 0, window = {win}, buffer = 0 }},
function() ran = true end)
rejected = (not ok) and tostring(err) or '<accepted>'"
),
);
let rejected: String = eval(&s, "return rejected");
assert!(
rejected.contains("cannot be constructed from Lua"),
"a forged table must be rejected by type, not merely fail later; got {rejected:?}"
);
assert!(
!eval::<bool>(&s, "return ran"),
"a rejected destination must not reach the callback"
);
}
/// **N6c** — a declining listener cannot redirect the destination.
///
/// The same userdata is handed to every listener in turn. As a table, an
/// earlier listener could rewrite the window and then decline, sending
/// the fallback somewhere the user never asked for. Falsified by passing
/// a shared mutable table.
#[test]
fn a_declining_listener_cannot_redirect_the_destination() {
let td = project();
let mut s = EditorState::new();
exec(&s, "pmacs.lsp.config = {}");
let target = local_window(&s);
exec(
&s,
"seen_first, seen_second, mutation = nil, nil, nil
pmacs.hook.add('path.open-directory', function(_, d)
seen_first = d:window()
-- Try to redirect, then decline. Both halves matter: a
-- successful mutation with a decline is the attack.
local ok, err = pcall(function() d.window = 999 end)
mutation = (not ok) and tostring(err) or '<mutated>'
end)
pmacs.hook.add('path.open-directory', function(_, d)
seen_second = d:window()
end)",
);
s.open_directory_target(td.path());
pump(&mut s);
let mutation: String = eval(&s, "return mutation");
assert!(
!mutation.contains("<mutated>"),
"the destination must be read-only; got {mutation:?}"
);
let first = eval::<i64>(&s, "return seen_first");
let second = eval::<i64>(&s, "return seen_second");
assert_eq!(
first, second,
"every listener must see the same, unaltered destination"
);
assert_eq!(
u64::try_from(second).expect("window id"),
target.raw(),
"and it must still name the window the editor captured"
);
// And the fallback commits THERE, not to whatever the first listener
// wanted -- the observable the attack was aiming at.
assert!(
active_name(&s).starts_with("*dired:"),
"the declined chain must still fall back to dired"
);
assert_eq!(
buffer_in(&s, target),
Some(eval::<pmacs::lua_bindings::BufferIdLua>(&s, "return pmacs.window.buffer()").0),
"in the captured window"
);
}
// --- the commit's preservation pins ---------------------------------------
/// **P1** — every destination precondition is checked *before* the
/// callback runs, so a failure mutates nothing.
///
/// Four refusals, each asserted the same way: `commit_to` returns
/// `(false, reason)`, the callback never ran, and no buffer was created.
/// Table-driven deliberately — the failure message names which
/// precondition regressed, which four separate near-identical tests
/// would give up in exchange for nothing.
///
/// *Mutation:* move the preflight from before the callback to after it
/// (rev 2's design, which validated at display time). All four fail.
/// *Second mutation, for the dedicated case:* pass `Some(dest.buffer)`
/// instead of `None` to `window_accepts_buffer`. Only that case fails —
/// which is why it is listed separately from the stale-buffer case it
/// otherwise resembles.
///
/// **Also fails on full revert**, since `commit_to` does not exist on the
/// pre-image. It is listed as a P because the discriminating falsifier is
/// the named mutation, not the revert: a revert-only check would not
/// distinguish "validates" from "validates in time".
#[test]
fn preservation_a_failed_precondition_never_reaches_the_callback() {
// (label, Lua that breaks the precondition, expected reason fragment)
let cases: [(&str, &str, &str); 4] = [
(
"frontend gone",
// Handled in Rust below: unregistering a view has no Lua surface.
"",
"requesting frontend is gone",
),
(
"window gone",
"local doomed = dest:window()
pmacs.window.split_horizontal()
while pmacs.window.current() == doomed do pmacs.window.focus_next() end
pmacs.window.close_others()",
"is gone",
),
(
"stale buffer",
"pmacs.window.switch_buffer(pmacs.buffer.create('*usurper*'))",
"now shows another buffer",
),
(
"dedicated",
"pmacs.window.set_params(dest:window(), { dedicated = true })",
"is dedicated",
),
];
for (label, break_it, expected) in cases {
let td = project();
let mut s = EditorState::new();
exec(&s, "pmacs.lsp.config = {}");
capture_dest(&mut s, td.path());
if label == "frontend gone" {
s.core
.borrow_mut()
.unregister_frontend_view(FrontendId::LOCAL);
} else {
exec(&s, break_it);
}
let before = buffer_count(&s);
exec(
&s,
"ran = false
ok, reason = pmacs.window.commit_to(dest, function() ran = true end)",
);
assert!(
!eval::<bool>(&s, "return ok"),
"{label}: commit_to must refuse"
);
let reason: String = eval(&s, "return tostring(reason)");
assert!(
reason.contains(expected),
"{label}: reason must say why; wanted {expected:?}, got {reason:?}"
);
assert!(
!eval::<bool>(&s, "return ran"),
"{label}: the callback must not run at all -- validating after it \
is four mutations too late"
);
assert_eq!(
buffer_count(&s),
before,
"{label}: a refused commit must create no buffer"
);
}
}
/// **P2 — stale intent loses**, through dired's real commit path.
///
/// The user replaced the destination window's buffer while the listing
/// was in flight. Their action is newer information than the request, so
/// the request loses: dired refuses, their buffer survives, and no dired
/// buffer or handle is left behind for that path.
///
/// P1 pins the preflight in isolation; this drives `pmacs.dired.open`
/// with a captured destination — the same call the handler makes — so
/// the atomicity claim is asserted where the four mutations actually
/// live.
///
/// *Mutation:* drop the `dest.buffer` comparison from the preflight
/// (window-only validation). The dired buffer then replaces the user's.
#[test]
fn preservation_a_stale_destination_loses_to_the_users_newer_buffer() {
let td = project();
let mut s = EditorState::new();
exec(&s, "pmacs.lsp.config = {}");
capture_dest(&mut s, td.path());
let target = local_window(&s);
// The user switches the destination window while the work is in flight.
exec(
&s,
"usurper = pmacs.buffer.create('*usurper*')
pmacs.window.switch_buffer(usurper)",
);
let usurper = buffer_in(&s, target);
let before = buffer_count(&s);
exec(
&s,
&format!(
"failure = nil
pmacs.async(function()
local ok, err = pcall(pmacs.dired.open, {:?}, {{ dest = dest }})
failure = (not ok) and tostring(err) or '<committed>'
end)",
canon(td.path())
),
);
pump(&mut s);
let failure: String = eval(&s, "return failure");
assert!(
failure.contains("destination is gone"),
"dired must report the refusal rather than commit; got {failure:?}"
);
assert_eq!(
buffer_in(&s, target),
usurper,
"the user's newer buffer must survive"
);
assert_eq!(
buffer_count(&s),
before,
"and no dired buffer may be left behind"
);
assert_eq!(
active_name(&s),
"*usurper*",
"nor may the refusal change what is displayed"
);
}
/// **P3** — dired reads its `prev` inside the scope, so `q` returns to
/// the *destination* window's buffer, not the ambient frontend's.
///
/// `handle.prev` is captured with `pmacs.window.buffer()`, whose no-arg
/// arm reads the core's ambient `active_buffer_id()`. That is precisely
/// why the scope swaps `core.active_frontend` and not only the override:
/// a scope that swapped the override alone would leave this one line
/// reading the competitor's buffer, and `q` would drop the user into a
/// buffer from another frontend's window.
///
/// Asserted through `q` rather than by reaching into dired's handle
/// table — `prev`'s entire meaning is where `q` lands.
///
/// *Mutation:* stop swapping `core.active_frontend` in
/// `ScopedFrontend::enter` (keep the override). `q` then lands in
/// `*competitor*`.
#[test]
fn preservation_dired_captures_prev_from_the_destination_not_the_ambient_frontend() {
let td = project();
let mut s = EditorState::new();
exec(&s, "pmacs.lsp.config = {}");
let target = local_window(&s);
let origin = buffer_in(&s, target).expect("the startup buffer");
// A competitor whose window shows a buffer of its own, ambient while
// the listing settles.
let other_win = attach_frontend(&s, COMPETITOR);
let competitor_buffer =
eval::<pmacs::lua_bindings::BufferIdLua>(&s, "return pmacs.buffer.create('*competitor*')")
.0;
s.core
.borrow_mut()
.install_buffer_in_window(other_win, competitor_buffer)
.expect("install");
s.core.borrow_mut().active_frontend = COMPETITOR;
s.open_directory_target(td.path());
pump(&mut s);
s.core.borrow_mut().active_frontend = FrontendId::LOCAL;
assert!(
active_name(&s).starts_with("*dired:"),
"the listing must have committed"
);
type_char(&mut s, 'q');
assert_eq!(
buffer_in(&s, target),
Some(origin),
"`q` must return to the buffer the DESTINATION window showed, not \
the ambient frontend's"
);
}
// ---------------------------------------------------------------------------
// 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"
);
}
// ---------------------------------------------------------------------------
// Step 9 — build or test the project (Journey Stage 1b-1)
//
// `COHERENCE.md` §2 graded this **Partial**: `M-x compile.run` worked but
// had no keybinding, an empty first prompt, and no `cargo build`
// suggestion. These rows are the ratchet for the three.
//
// The trap these fixtures are built around: compile's last-resort cwd is
// `std::env::current_dir()` evaluated at call time, which under `cargo
// test` is the **pmacs repo root — itself a Cargo project**
// (`compile_mode_acceptance.rs` pins exactly that). So any pin asserting
// the *absence* of a Cargo suggestion must be shaped so the fallback is
// never consulted: each fixture carries its own marker, and
// `project_root_of_active()` therefore answers from inside the fixture.
// `set_search_boundary` is not the defence — it clamps only a walk that
// starts below the boundary, and the fallback's walk starts at the repo.
// ---------------------------------------------------------------------------
fn ctrl(s: &mut EditorState, c: char) {
s.dispatch_key(
FrontendId::LOCAL,
key(KeyCode::Char(c), KeyModifiers::CONTROL),
);
}
/// Open the compile prompt through the real `C-c c` binding.
fn press_compile_chord(s: &mut EditorState) {
ctrl(s, 'c');
type_char(s, 'c');
}
/// A Cargo project a journey can plausibly be run against.
fn cargo_project() -> TempDir {
let td = tempfile::tempdir().expect("tempdir");
std::fs::write(
td.path().join("Cargo.toml"),
b"[package]\nname = \"journey-fixture\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
)
.expect("write Cargo.toml");
std::fs::create_dir_all(td.path().join("src")).expect("mkdir src");
std::fs::write(td.path().join("src/main.rs"), b"fn main() {}\n").expect("write main.rs");
td
}
/// Clamp detection to the fixture so a stray marker above the tempdir
/// (a developer's `/tmp/.git`) cannot leak in.
fn bound_detection_to(s: &EditorState, dir: &Path) {
exec(
s,
&format!(
"pmacs.project.set_search_boundary({:?})",
dir.display().to_string()
),
);
}
/// The project root as **detection** reports it.
///
/// `pmacs.project.detect` canonicalizes before walking
/// (`canonicalize_or_passthrough`, `src/project.rs:509-511`), so a
/// `/var/folders/...` tempdir on macOS comes back as
/// `/private/var/folders/...`. `canon()` is *lexical* — it never
/// resolves symlinks — so it is the wrong expectation for any value
/// that has passed through detection, which is exactly what the compile
/// cwd is. Using it here failed both macOS CI legs while Ubuntu (where
/// `/tmp` is not a symlink) stayed green.
fn detected_root(path: &Path) -> String {
std::fs::canonicalize(path)
.unwrap_or_else(|_| path.to_path_buf())
.to_string_lossy()
.into_owned()
}
fn minibuffer_active(s: &EditorState) -> bool {
eval(s, "return pmacs.minibuffer.is_active()")
}
fn minibuffer_contents(s: &EditorState) -> String {
eval(s, "return pmacs.minibuffer.contents()")
}
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 ""
"#
),
)
}
/// Drive frames until `pred` holds, pumping the process supervisor as
/// well as the async runtime — a compile run is a spawned child.
fn pump_processes_until(
s: &mut EditorState,
timeout_ms: u64,
mut pred: impl FnMut(&EditorState) -> bool,
) -> bool {
let stop = Instant::now() + Duration::from_millis(timeout_ms);
loop {
if pred(s) {
return true;
}
if Instant::now() >= stop {
return false;
}
s.tick_processes();
s.tick_async();
std::thread::sleep(Duration::from_millis(5));
}
}
/// Walk the journey to the point where a build would be run: launch on
/// the directory, then `RET` on `name` to open a file.
fn walk_to_open_file(dir: &Path, name: &str) -> EditorState {
let mut s = launch(dir);
bound_detection_to(&s, dir);
let line = line_of(&s, name);
exec(&s, &format!("pmacs.editor.move_to_line({line})"));
press(&mut s, KeyCode::Enter);
pump(&mut s);
assert_eq!(
active_name(&s),
dir.join(name).display().to_string(),
"precondition: the walk must actually open the file, or every pin \
below is really testing the dired buffer"
);
s
}
/// **N** — `C-c c` reaches `compile.run`. Separate from N2 because a
/// prefill assertion alone stays green if the binding is removed and the
/// prompt is opened another way — and the binding is the thing
/// `COHERENCE.md` says is missing.
#[test]
fn journey_step9_the_compile_chord_opens_the_prompt() {
let td = cargo_project();
let mut s = walk_to_open_file(td.path(), "Cargo.toml");
press_compile_chord(&mut s);
assert!(
minibuffer_active(&s),
"C-c c must open the compile prompt; active buffer is {}",
active_name(&s)
);
}
/// **N** — the prompt is prefilled from the detected project kind.
#[test]
fn journey_step9_the_prompt_is_prefilled_for_a_cargo_project() {
let td = cargo_project();
let mut s = walk_to_open_file(td.path(), "Cargo.toml");
press_compile_chord(&mut s);
assert_eq!(
minibuffer_contents(&s),
"cargo build",
"a Rust project's first compile prompt must offer its build command"
);
}
/// **N** — the directory the prompt captured survives a window switch.
///
/// The prompt is opened against A, the active buffer then moves to an
/// unrelated directory B, and only then is the command accepted. Both
/// readings — the header pmacs writes and the shell's own `pwd` — must
/// say A. Falsified by dropping `{ cwd = ctx.cwd }` from `on_accept`,
/// which is what re-resolving at accept time looks like.
#[test]
fn journey_step9_the_prompt_runs_in_the_directory_it_captured() {
let a = cargo_project();
let b = project();
let mut s = walk_to_open_file(a.path(), "Cargo.toml");
press_compile_chord(&mut s);
assert!(minibuffer_active(&s), "prompt must be open against A");
// Move the active buffer to B while the prompt waits for input.
exec(
&s,
&format!(
"pmacs.window.display_file({:?})",
b.path().join("alpha.txt").display().to_string()
),
);
pump(&mut s);
// Accept a cheap command: the subject is the directory, so a fast
// one keeps the failure message about the directory. Only the
// editing of an already-open prompt is short-circuited; the prompt
// itself was opened through the real chord.
exec(&s, "pmacs.minibuffer.set_contents('pwd')");
press(&mut s, KeyCode::Enter);
let finished = pump_processes_until(&mut s, 10_000, |s| {
named_text(s, "*compilation*").contains("exited")
});
assert!(finished, "the accepted run must finish");
let text = named_text(&s, "*compilation*");
// Detection-canonical, not lexical: the compile cwd came from
// `pmacs.project.detect`, and `pwd` reports the physical directory.
let a_path = detected_root(a.path());
assert!(
text.contains(&format!("Directory: {a_path}")),
"the header must name the directory the prompt captured, not the newly active one;\n{text}"
);
assert!(
text.contains(&a_path),
"and `pwd` must agree with the header;\n{text}"
);
}
/// **N** — the offered command runs, in the offered directory.
///
/// Every other pin here compares values the prompt and the resolver
/// already agree on. A wrong directory inside `on_accept` passes all of
/// them. This one accepts what was offered, unedited, and observes a
/// real process.
#[test]
fn journey_step9_the_offered_command_builds_the_project() {
if !binary_available("cargo") {
assert!(
std::env::var_os("PMACS_REQUIRE_CARGO_BUILD").is_none(),
"PMACS_REQUIRE_CARGO_BUILD is set but `cargo` is not on PATH"
);
eprintln!("skipping: `cargo` is not on PATH");
return;
}
let td = cargo_project();
let mut s = walk_to_open_file(td.path(), "Cargo.toml");
press_compile_chord(&mut s);
assert_eq!(
minibuffer_contents(&s),
"cargo build",
"precondition: the prompt offers the build command"
);
// Accept exactly what was offered — no editing.
press(&mut s, KeyCode::Enter);
let finished = pump_processes_until(&mut s, 120_000, |s| {
named_text(s, "*compilation*").contains("exited")
});
assert!(finished, "the offered build must finish");
let text = named_text(&s, "*compilation*");
assert!(
text.contains(&format!("Directory: {}", detected_root(td.path()))),
"the build runs in the detected project root;\n{text}"
);
assert!(
text.contains("[compile exited with code 0]"),
"and the command that was offered is one that actually runs;\n{text}"
);
// Positive control on the run itself. A clean exit alone does not
// prove cargo compiled anything — this names the fixture crate, so
// the pin fails if the offered command ever stops being a build.
assert!(
text.contains("journey-fixture"),
"cargo must actually have built the fixture crate;\n{text}"
);
}
/// **N** — the suggestion follows the directory the run will use.
///
/// A Node project nested inside a Cargo one, opened at the *inner* file.
/// Falsified by deriving the kind from the launch directory (the Cargo
/// root here) or from the process cwd — both yield `cargo build`.
///
/// It does **not** catch re-detecting from `project_root_of_active()`'s
/// answer: that helper already returns the innermost root, so detecting
/// from it yields `node` again and this pin stays green.
#[test]
fn journey_step9_a_nested_project_gets_its_own_kind_not_the_outer_one() {
let outer = cargo_project();
let sub = outer.path().join("sub");
std::fs::create_dir_all(&sub).expect("mkdir sub");
std::fs::write(sub.join("package.json"), b"{ \"name\": \"inner\" }\n").expect("write pkg");
std::fs::write(sub.join("index.js"), b"console.log(1)\n").expect("write index.js");
let mut s = walk_to_open_file(&sub, "index.js");
press_compile_chord(&mut s);
// `minibuffer_contents` is "" both for an empty prefill and for no
// minibuffer at all, so the emptiness assertion below is vacuous
// without this. Bite A caught it: with the binding removed this pin
// stayed green while six others failed.
assert!(
minibuffer_active(&s),
"precondition: the prompt is actually open"
);
assert_eq!(
eval::<String>(&s, "return pmacs.compile.context().kind"),
"node",
"the kind is detected from the resolved cwd, not the launch directory"
);
assert_eq!(
minibuffer_contents(&s),
"",
"and `node` has no seeded default, so nothing is offered"
);
}
/// **N** — `context()` is total in a launched session.
///
/// Asserted with the pathless dired buffer active, so the fallback
/// branch is the one under test. It pins a *property*, deliberately not
/// a value: which directory the fallback resolves to is the test
/// runner's cwd and pinning it would pin the environment.
#[test]
fn journey_step9_the_compile_context_is_total_even_with_no_file_open() {
let td = cargo_project();
let s = launch(td.path());
assert!(
active_name(&s).starts_with("*dired:"),
"precondition: the pathless dired buffer is active"
);
assert!(
eval::<bool>(&s, "return pmacs.compile.context().cwd ~= nil"),
"a launched session always has somewhere to run a build"
);
assert!(
eval::<bool>(
&s,
"local c = pmacs.compile.context()
local ok, p = pcall(pmacs.project.detect, c.cwd)
local detected = (ok and p) and p.kind or nil
return c.kind == detected"
),
"and its kind is exactly what detection answers for that cwd"
);
}
/// **P** — `_last` still outranks the kind default.
///
/// Green on the pre-image (there was no default). The targeted mutation
/// is reordering the precedence chain to put `defaults[kind]` first.
#[test]
fn journey_step9_preservation_the_last_command_outranks_the_default() {
let td = cargo_project();
let mut s = walk_to_open_file(td.path(), "Cargo.toml");
exec(&s, "pmacs.compile.run('true')");
let finished = pump_processes_until(&mut s, 10_000, |s| {
named_text(s, "*compilation*").contains("exited")
});
assert!(finished, "precondition: a run completed and set _last");
press_compile_chord(&mut s);
assert_eq!(
minibuffer_contents(&s),
"true",
"a session that has compiled keeps its own command, not the project default"
);
}
/// **P** — the compile keys that already existed still dispatch.
///
/// Targeted mutation: writing the new binding as an unbind+bind pair
/// over one of these sequences.
#[test]
fn journey_step9_preservation_the_existing_compile_bindings_survive() {
let td = cargo_project();
let s = walk_to_open_file(td.path(), "Cargo.toml");
for (sequence, command) in [
("M-g n", "error.next"),
("M-g p", "error.previous"),
("C-x `", "error.next"),
("M-!", "shell.command"),
("C-c c", "compile.run"),
] {
let bound: String = eval(
&s,
&format!("return pmacs.keymap.lookup({sequence:?}).command"),
);
assert_eq!(
bound, command,
"{sequence} must still dispatch to {command}"
);
}
}
/// True when `name` resolves on PATH — the fixture-dependency gate the
/// process suite uses, so a missing binary skips rather than fails.
fn binary_available(name: &str) -> bool {
std::process::Command::new("which")
.arg(name)
.output()
.is_ok_and(|o| o.status.success())
}
/// **N** — the compile directory is *detection*-canonical, not lexical.
///
/// This pin exists because its absence broke both macOS CI legs while
/// Ubuntu stayed green: `/var` is a symlink to `/private/var` there, so
/// a tempdir's lexical and canonical paths differ, and the original
/// assertions used the lexical one.
///
/// Reproducing it on Linux needs an explicit symlink — which is also a
/// real configuration `Workspace::detect`'s own doc comment names
/// ("`/tmp/sandbox/foo` symlinked to `/home/user/code/foo`"). Launching
/// through the link makes the two paths disagree on every platform, so
/// the regression can no longer hide behind a filesystem that happens
/// not to use symlinks.
#[cfg(unix)]
#[test]
fn journey_step9_the_compile_directory_is_detection_canonical() {
let parent = tempfile::tempdir().expect("tempdir");
let real = parent.path().join("real");
std::fs::create_dir_all(real.join("src")).expect("mkdir real");
std::fs::write(
real.join("Cargo.toml"),
b"[package]\nname = \"journey-fixture\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
)
.expect("write Cargo.toml");
std::fs::write(real.join("src/main.rs"), b"fn main() {}\n").expect("write main.rs");
let link = parent.path().join("link");
std::os::unix::fs::symlink(&real, &link).expect("symlink");
// Precondition, or the pin is vacuous: the two spellings must
// actually differ, which is the whole condition macOS creates for
// free and Linux does not.
assert_ne!(
canon(&link),
detected_root(&link),
"the fixture must make lexical and canonical paths disagree"
);
let mut s = walk_to_open_file(&link, "Cargo.toml");
press_compile_chord(&mut s);
exec(&s, "pmacs.minibuffer.set_contents('pwd')");
press(&mut s, KeyCode::Enter);
let finished = pump_processes_until(&mut s, 10_000, |s| {
named_text(s, "*compilation*").contains("exited")
});
assert!(finished, "the accepted run must finish");
let text = named_text(&s, "*compilation*");
assert!(
text.contains(&format!("Directory: {}", detected_root(&link))),
"the header must name the directory detection resolved to;\n{text}"
);
}