754 lines
27 KiB
Rust
754 lines
27 KiB
Rust
//! Terminal configuration acceptance (Stage 1 of
|
|
//! `docs/terminal-config-and-copy-mode-framing.md`, criteria 1-12).
|
|
//!
|
|
//! Deliberately NOT `#[cfg(feature = "crdt")]`: CI never enables that
|
|
//! feature, so a gated suite is written and then never run.
|
|
|
|
use std::thread;
|
|
use std::time::{Duration, Instant};
|
|
|
|
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
|
use mlua::Value;
|
|
use pmacs::cell::{CellSize, Glyph};
|
|
use pmacs::editor::EditorState;
|
|
use pmacs::protocol::FrontendId;
|
|
use pmacs::terminal::TerminalViewKey;
|
|
use pmacs::window::WindowId;
|
|
|
|
fn exec(state: &EditorState, src: &str) {
|
|
state
|
|
.lua_host
|
|
.lua()
|
|
.load(src)
|
|
.exec()
|
|
.unwrap_or_else(|e| panic!("lua failed: {src}\n{e}"));
|
|
}
|
|
|
|
fn eval_err(state: &EditorState, src: &str) -> String {
|
|
let result: mlua::Result<Value> = state.lua_host.lua().load(src).eval();
|
|
match result {
|
|
Ok(_) => panic!("expected an error from: {src}"),
|
|
Err(e) => e.to_string(),
|
|
}
|
|
}
|
|
|
|
/// The viewport every test projects through. Deliberately SHORTER than
|
|
/// the 24-row screen a terminal opens with, so "scroll to the oldest
|
|
/// retained row" has somewhere to go even when nothing is retained —
|
|
/// which is what makes the two scrollback arms differ by content rather
|
|
/// than by whether scrolling was possible at all.
|
|
fn viewport() -> CellSize {
|
|
CellSize::new(10, 40)
|
|
}
|
|
|
|
fn cells_to_text(cells: &[pmacs::cell::Cell]) -> String {
|
|
let mut text = String::new();
|
|
for cell in cells {
|
|
match &cell.glyph {
|
|
Glyph::Char(c) => text.push(*c),
|
|
Glyph::Cluster(b) => text.push_str(&String::from_utf8_lossy(b)),
|
|
Glyph::Continuation => {}
|
|
}
|
|
}
|
|
text
|
|
}
|
|
|
|
fn screen_text(state: &EditorState, buffer: pmacs::buffer::BufferId) -> String {
|
|
let manager = state.terminal_manager.borrow();
|
|
let Some(snapshot) = manager.snapshot(buffer) else {
|
|
return String::new();
|
|
};
|
|
cells_to_text(&snapshot.cells)
|
|
}
|
|
|
|
/// Text a view actually shows, which is where retained history is
|
|
/// visible at all — the live `screen_text` above always reads the tail.
|
|
fn view_text(state: &EditorState, key: TerminalViewKey) -> String {
|
|
let mut manager = state.terminal_manager.borrow_mut();
|
|
manager
|
|
.snapshot_for_view(key, viewport())
|
|
.map(|snapshot| cells_to_text(&snapshot.cells))
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
/// Scroll a view to its OLDEST retained row and read it back.
|
|
fn oldest_view_text(state: &EditorState, key: TerminalViewKey) -> String {
|
|
state
|
|
.terminal_manager
|
|
.borrow_mut()
|
|
.scroll_view(key, viewport(), i32::MAX);
|
|
view_text(state, key)
|
|
}
|
|
|
|
fn tick_until(state: &mut EditorState, needle: &str, buffer: pmacs::buffer::BufferId) -> bool {
|
|
let deadline = Instant::now() + Duration::from_secs(5);
|
|
loop {
|
|
state.tick_processes();
|
|
if screen_text(state, buffer).contains(needle) {
|
|
return true;
|
|
}
|
|
if Instant::now() >= deadline {
|
|
return false;
|
|
}
|
|
thread::sleep(Duration::from_millis(20));
|
|
}
|
|
}
|
|
|
|
/// Give LOCAL a window on `buffer` and register/claim its terminal view,
|
|
/// which is what makes `dispatch_key`'s terminal arm reachable.
|
|
fn focus_terminal(state: &EditorState, buffer: pmacs::buffer::BufferId) -> WindowId {
|
|
state.core.borrow_mut().switch_active_buffer(buffer).ok();
|
|
let window = state.core.borrow().active_window_id();
|
|
let key = TerminalViewKey::new(FrontendId::LOCAL, window, buffer);
|
|
let mut manager = state.terminal_manager.borrow_mut();
|
|
manager.register_view(key);
|
|
manager.claim_controller(key);
|
|
let _ = manager.snapshot_for_view(key, viewport());
|
|
window
|
|
}
|
|
|
|
fn terminal_buffers(state: &EditorState) -> Vec<pmacs::buffer::BufferId> {
|
|
let manager = state.terminal_manager.borrow();
|
|
state
|
|
.core
|
|
.borrow()
|
|
.registry
|
|
.borrow()
|
|
.ids()
|
|
.iter()
|
|
.copied()
|
|
.filter(|id| manager.is_terminal(*id))
|
|
.collect()
|
|
}
|
|
|
|
/// Open a terminal from Lua and return the identity buffer it created.
|
|
///
|
|
/// The id is derived by diffing the manager's terminal set rather than
|
|
/// returned through Lua: `BufferIdLua` exposes no id accessor, and
|
|
/// diffing also asserts in passing that exactly one terminal appeared.
|
|
fn open_cat_terminal(state: &EditorState, lua_spec: &str) -> pmacs::buffer::BufferId {
|
|
let before = terminal_buffers(state);
|
|
exec(
|
|
state,
|
|
&format!("TERM_BUF = pmacs.terminal.open {{ {lua_spec} }}"),
|
|
);
|
|
let after = terminal_buffers(state);
|
|
let mut fresh: Vec<_> = after
|
|
.into_iter()
|
|
.filter(|id| !before.contains(id))
|
|
.collect();
|
|
assert_eq!(fresh.len(), 1, "exactly one terminal must have opened");
|
|
fresh.remove(0)
|
|
}
|
|
|
|
/// `cat -v` is the echo instrument, deliberately: the terminal screen
|
|
/// rejects C0/C1 controls before they enter cells (Vterm Stage 1
|
|
/// criterion 2), so a raw echoed `Ctrl-X` would be invisible and a test
|
|
/// probing for it could never pass. `-v` renders it as the printable
|
|
/// two-character `^X`, which is what makes "the configured chord reached
|
|
/// the child" observable at all.
|
|
const CAT_PROFILE: &str = r#"
|
|
pmacs.terminal.profiles.echo = {
|
|
command = "/bin/sh",
|
|
args = { "-c", "printf 'READY\r\n'; exec cat -v" },
|
|
}
|
|
"#;
|
|
|
|
/// Did the last key ARM the terminal escape?
|
|
///
|
|
/// Observed behaviorally rather than through an accessor: while the
|
|
/// escape is armed the next key goes to ordinary dispatch, so it never
|
|
/// reaches the child. `cat` echoes anything that does reach it, which
|
|
/// makes "the probe character did not appear" the exact observable for
|
|
/// "that chord was consumed as the escape".
|
|
fn escape_was_armed(state: &mut EditorState, buffer: pmacs::buffer::BufferId, probe: char) -> bool {
|
|
// Count occurrences rather than testing for presence: the screen
|
|
// already holds the child's own output, and a single-character probe
|
|
// like 'R' collides with the "READY" banner. Only an INCREASE proves
|
|
// this keystroke reached the child.
|
|
let before = screen_text(state, buffer).matches(probe).count();
|
|
state.dispatch_key(
|
|
FrontendId::LOCAL,
|
|
KeyEvent::new(KeyCode::Char(probe), KeyModifiers::NONE),
|
|
);
|
|
let deadline = Instant::now() + Duration::from_secs(2);
|
|
loop {
|
|
state.tick_processes();
|
|
if screen_text(state, buffer).matches(probe).count() > before {
|
|
return false;
|
|
}
|
|
if Instant::now() >= deadline {
|
|
return true;
|
|
}
|
|
thread::sleep(Duration::from_millis(20));
|
|
}
|
|
}
|
|
|
|
/// Acceptance 1: a profile spec is strict, and rejects before anything spawns.
|
|
#[test]
|
|
fn acc1_profile_specs_are_strict_and_reject_before_spawning() {
|
|
let state = EditorState::new_with_roots(&crate::iso::roots());
|
|
let before = state.core.borrow().registry.borrow().ids().len();
|
|
|
|
exec(
|
|
&state,
|
|
r#"pmacs.terminal.profiles.bad = { command = "/bin/sh", nonsense = true }"#,
|
|
);
|
|
let err = eval_err(&state, r#"return pmacs.terminal.open { profile = "bad" }"#);
|
|
assert!(
|
|
err.contains("unknown field") && err.contains("nonsense"),
|
|
"the error must name the offending field: {err}"
|
|
);
|
|
|
|
exec(&state, "pmacs.terminal.profiles.wrong = { command = 42 }");
|
|
let err = eval_err(
|
|
&state,
|
|
r#"return pmacs.terminal.open { profile = "wrong" }"#,
|
|
);
|
|
assert!(err.contains("must be a string"), "typed field error: {err}");
|
|
|
|
assert_eq!(
|
|
state.core.borrow().registry.borrow().ids().len(),
|
|
before,
|
|
"a rejected profile must create no buffer"
|
|
);
|
|
assert_eq!(state.terminal_manager.borrow().len(), 0);
|
|
}
|
|
|
|
/// Acceptance 2: an unknown profile names the known ones and creates nothing.
|
|
#[test]
|
|
fn acc2_unknown_profile_lists_known_names_and_creates_nothing() {
|
|
let state = EditorState::new_with_roots(&crate::iso::roots());
|
|
exec(&state, CAT_PROFILE);
|
|
exec(
|
|
&state,
|
|
r#"pmacs.terminal.profiles.other = { command = "/bin/sh" }"#,
|
|
);
|
|
let before = state.core.borrow().registry.borrow().ids().len();
|
|
|
|
// Via the default setting.
|
|
exec(
|
|
&state,
|
|
r#"pmacs.config.set("terminal.default-profile", "ghost")"#,
|
|
);
|
|
let err = eval_err(&state, "return pmacs.terminal.open {}");
|
|
assert!(err.contains("ghost"), "names the missing profile: {err}");
|
|
assert!(
|
|
err.contains("echo") && err.contains("other"),
|
|
"must LIST the known profiles: {err}"
|
|
);
|
|
|
|
// An explicit bad profile fails even though the default is now valid —
|
|
// a typo must not silently fall back (Q#TC3a).
|
|
exec(
|
|
&state,
|
|
r#"pmacs.config.set("terminal.default-profile", "echo")"#,
|
|
);
|
|
let err = eval_err(&state, r#"return pmacs.terminal.open { profile = "typo" }"#);
|
|
assert!(err.contains("typo"), "explicit bad profile errors: {err}");
|
|
|
|
assert_eq!(
|
|
state.core.borrow().registry.borrow().ids().len(),
|
|
before,
|
|
"no buffer, session, or process is created"
|
|
);
|
|
assert_eq!(state.terminal_manager.borrow().len(), 0);
|
|
}
|
|
|
|
/// Acceptance 2 (malformed table): `pmacs.terminal.profiles` is a raw
|
|
/// user table, so a diagnostic that walks its keys must be total over
|
|
/// them. A table holding both a string and a numeric key made
|
|
/// `table.sort` raise "attempt to compare number with string" — on the
|
|
/// unknown-profile path, replacing the exact error being asked for.
|
|
#[test]
|
|
fn acc2_malformed_profile_keys_do_not_mask_the_unknown_profile_error() {
|
|
let state = EditorState::new_with_roots(&crate::iso::roots());
|
|
exec(&state, CAT_PROFILE);
|
|
exec(
|
|
&state,
|
|
r#"pmacs.terminal.profiles[1] = { command = "/bin/sh" }"#,
|
|
);
|
|
|
|
let err = eval_err(
|
|
&state,
|
|
r#"return pmacs.terminal.open { profile = "ghost" }"#,
|
|
);
|
|
assert!(
|
|
err.contains("ghost") && err.contains("echo"),
|
|
"the unknown-profile error must survive a malformed table: {err}"
|
|
);
|
|
assert!(
|
|
!err.contains("attempt to compare"),
|
|
"listing known profiles must not raise: {err}"
|
|
);
|
|
|
|
// Rendering the REQUESTED name is partial too: `%q` raises on a
|
|
// table, and the name arrives straight from the caller.
|
|
let err = eval_err(&state, r"return pmacs.terminal.open { profile = {} }");
|
|
assert!(
|
|
err.contains("is not defined") && err.contains("known profiles"),
|
|
"a non-string profile name must render, not raise: {err}"
|
|
);
|
|
|
|
assert_eq!(state.terminal_manager.borrow().len(), 0);
|
|
}
|
|
|
|
/// Acceptance 3: explicit beats profile beats setting beats `$SHELL`, and
|
|
/// `env` MERGES rather than replacing.
|
|
#[test]
|
|
fn acc3_field_resolution_order_and_env_merge() {
|
|
let mut state = EditorState::new_with_roots(&crate::iso::roots());
|
|
exec(
|
|
&state,
|
|
r#"
|
|
pmacs.terminal.profiles.merged = {
|
|
command = "/bin/sh",
|
|
args = { "-c", "printf 'PROFILE:%s:%s\r\n' \"$FROM_PROFILE\" \"$SHARED\"; exec cat" },
|
|
env = { FROM_PROFILE = "p", SHARED = "profile" },
|
|
}
|
|
"#,
|
|
);
|
|
let buffer = open_cat_terminal(
|
|
&state,
|
|
r#"profile = "merged", env = { SHARED = "explicit" }"#,
|
|
);
|
|
assert!(
|
|
tick_until(&mut state, "PROFILE:p:explicit", buffer),
|
|
"profile env survives and explicit env overrides the same key: {:?}",
|
|
screen_text(&state, buffer)
|
|
);
|
|
state.process_supervisor.borrow_mut().shutdown();
|
|
}
|
|
|
|
/// Acceptance 3 (explicit command wins) and 4 (`""` means no profile).
|
|
#[test]
|
|
fn acc3_acc4_explicit_command_wins_and_empty_default_means_no_profile() {
|
|
let mut state = EditorState::new_with_roots(&crate::iso::roots());
|
|
exec(&state, CAT_PROFILE);
|
|
exec(
|
|
&state,
|
|
r#"pmacs.config.set("terminal.default-profile", "echo")"#,
|
|
);
|
|
|
|
// Explicit command beats the profile's.
|
|
let explicit = open_cat_terminal(
|
|
&state,
|
|
r#"command = "/bin/sh", args = { "-c", "printf 'EXPLICIT\r\n'; exec cat" }"#,
|
|
);
|
|
assert!(tick_until(&mut state, "EXPLICIT", explicit));
|
|
|
|
// `""` is the no-profile sentinel: falls through to $SHELL.
|
|
exec(
|
|
&state,
|
|
r#"pmacs.config.set("terminal.default-profile", "")"#,
|
|
);
|
|
let bare = open_cat_terminal(&state, "");
|
|
let spec_ok = state.terminal_manager.borrow().is_terminal(bare);
|
|
assert!(spec_ok, "an empty default must open a $SHELL terminal");
|
|
assert!(
|
|
!screen_text(&state, bare).contains("READY"),
|
|
"the echo profile must NOT have been applied"
|
|
);
|
|
state.process_supervisor.borrow_mut().shutdown();
|
|
}
|
|
|
|
/// A child that overflows the 24-row screen and then goes quiet, so its
|
|
/// early output can only still be found in RETAINED HISTORY. Zero-padded
|
|
/// so `LINE001` is not a substring of `LINE100`.
|
|
const FILL_PROFILE: &str = r#"
|
|
pmacs.terminal.profiles.fill = {
|
|
command = "/bin/sh",
|
|
args = { "-c",
|
|
"i=1; while [ $i -le 200 ]; do printf 'LINE%03d\r\n' $i; i=$((i+1)); done; printf 'DONE\r\n'; exec cat" },
|
|
}
|
|
"#;
|
|
|
|
/// Acceptance 5: the scrollback SETTING reaches the screen's retained
|
|
/// history, an explicit spec value overrides it, and `0` is legal.
|
|
///
|
|
/// Asserted end to end, through a real child and a real view, rather
|
|
/// than by reading the value back out of the registry: a registry
|
|
/// round-trip is a test of the registry, and would stay green with the
|
|
/// setting's only consumer (`terminal.lua`'s `resolved.scrollback_rows`
|
|
/// fallback) deleted outright.
|
|
#[test]
|
|
fn acc5_scrollback_setting_reaches_retained_history() {
|
|
let mut state = EditorState::new_with_roots(&crate::iso::roots());
|
|
exec(&state, FILL_PROFILE);
|
|
|
|
// Arm 1: `0` is legal, and means the early rows are GONE.
|
|
exec(&state, r#"pmacs.config.set("terminal.scrollback-rows", 0)"#);
|
|
let none = open_cat_terminal(&state, r#"profile = "fill""#);
|
|
assert!(tick_until(&mut state, "DONE", none), "child finished");
|
|
let window = focus_terminal(&state, none);
|
|
let none_key = TerminalViewKey::new(FrontendId::LOCAL, window, none);
|
|
let oldest = oldest_view_text(&state, none_key);
|
|
assert!(
|
|
!oldest.contains("LINE001"),
|
|
"with scrollback 0 the oldest retained row must not be the \
|
|
child's first line: {oldest:?}"
|
|
);
|
|
|
|
// Arm 2: a large setting retains it, reachable by scrolling back.
|
|
exec(
|
|
&state,
|
|
r#"pmacs.config.set("terminal.scrollback-rows", 10000)"#,
|
|
);
|
|
let kept = open_cat_terminal(&state, r#"profile = "fill""#);
|
|
assert!(tick_until(&mut state, "DONE", kept), "child finished");
|
|
let window = focus_terminal(&state, kept);
|
|
let kept_key = TerminalViewKey::new(FrontendId::LOCAL, window, kept);
|
|
let oldest = oldest_view_text(&state, kept_key);
|
|
assert!(
|
|
oldest.contains("LINE001"),
|
|
"with scrollback 10000 the first line must survive in history: \
|
|
{oldest:?}"
|
|
);
|
|
|
|
// Arm 3: an explicit spec value beats the setting, which is still 10000.
|
|
let overridden = open_cat_terminal(&state, r#"profile = "fill", scrollback_rows = 0"#);
|
|
assert!(tick_until(&mut state, "DONE", overridden), "child finished");
|
|
let window = focus_terminal(&state, overridden);
|
|
let overridden_key = TerminalViewKey::new(FrontendId::LOCAL, window, overridden);
|
|
let oldest = oldest_view_text(&state, overridden_key);
|
|
assert!(
|
|
!oldest.contains("LINE001"),
|
|
"an explicit scrollback_rows = 0 must beat the setting: {oldest:?}"
|
|
);
|
|
|
|
state.process_supervisor.borrow_mut().shutdown();
|
|
}
|
|
|
|
/// Acceptance 5 (bounds): the registered range rejects out-of-range
|
|
/// values, and `0` is inside it rather than a disabled sentinel.
|
|
#[test]
|
|
fn acc5_scrollback_bounds() {
|
|
let state = EditorState::new_with_roots(&crate::iso::roots());
|
|
exec(&state, r#"pmacs.config.set("terminal.scrollback-rows", 0)"#);
|
|
assert_eq!(
|
|
state
|
|
.lua_host
|
|
.lua()
|
|
.load(r#"return pmacs.config.get("terminal.scrollback-rows")"#)
|
|
.eval::<i64>()
|
|
.unwrap(),
|
|
0,
|
|
"0 is a legal scrollback value meaning 'retain no history'"
|
|
);
|
|
|
|
let err = eval_err(
|
|
&state,
|
|
r#"return pmacs.config.set("terminal.scrollback-rows", -1)"#,
|
|
);
|
|
assert!(
|
|
err.contains("-1") || err.contains("min"),
|
|
"below range: {err}"
|
|
);
|
|
let err = eval_err(
|
|
&state,
|
|
r#"return pmacs.config.set("terminal.scrollback-rows", 4000001)"#,
|
|
);
|
|
assert!(
|
|
err.contains("4000001") || err.contains("max"),
|
|
"above range: {err}"
|
|
);
|
|
}
|
|
|
|
/// Acceptance 6 and 9: the configured chord escapes, repeating it sends
|
|
/// THAT chord to the child, and an ordinary `C-c` still reaches the child.
|
|
#[test]
|
|
fn acc6_acc9_configured_escape_chord_and_literal_repeat() {
|
|
let mut state = EditorState::new_with_roots(&crate::iso::roots());
|
|
exec(&state, CAT_PROFILE);
|
|
let buffer = open_cat_terminal(&state, r#"profile = "echo""#);
|
|
assert!(tick_until(&mut state, "READY", buffer));
|
|
focus_terminal(&state, buffer);
|
|
|
|
exec(&state, r#"pmacs.config.set("terminal.escape-key", "C-x")"#);
|
|
|
|
// `C-x C-x` must send Ctrl-X (0x18), which `cat` echoes back. Against
|
|
// the pre-change hardcoded `&[0x03]` this sends Ctrl-C instead.
|
|
state.dispatch_key(
|
|
FrontendId::LOCAL,
|
|
KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL),
|
|
);
|
|
state.dispatch_key(
|
|
FrontendId::LOCAL,
|
|
KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL),
|
|
);
|
|
assert!(
|
|
tick_until(&mut state, "^X", buffer),
|
|
"C-x C-x must send literal Ctrl-X: {:?}",
|
|
screen_text(&state, buffer)
|
|
);
|
|
|
|
// With the escape moved, an ordinary C-c is just another key.
|
|
state.dispatch_key(
|
|
FrontendId::LOCAL,
|
|
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
|
|
);
|
|
assert!(
|
|
tick_until(&mut state, "^C", buffer),
|
|
"plain C-c must reach the child once the escape moved: {:?}",
|
|
screen_text(&state, buffer)
|
|
);
|
|
state.process_supervisor.borrow_mut().shutdown();
|
|
}
|
|
|
|
/// Acceptance 7, 8 and 8a: per-terminal escape resolution, an A→B→A parse
|
|
/// count that does not grow, and a cache that dies with its terminal.
|
|
#[test]
|
|
fn acc7_acc8_acc8a_per_terminal_escape_cache_identity_and_lifecycle() {
|
|
let mut state = EditorState::new_with_roots(&crate::iso::roots());
|
|
exec(&state, CAT_PROFILE);
|
|
let a = open_cat_terminal(&state, r#"profile = "echo""#);
|
|
exec(&state, "TERM_A = TERM_BUF");
|
|
let b = open_cat_terminal(&state, r#"profile = "echo""#);
|
|
exec(&state, "TERM_B = TERM_BUF");
|
|
assert!(tick_until(&mut state, "READY", a));
|
|
assert!(tick_until(&mut state, "READY", b));
|
|
|
|
// Different buffer-local escapes, then NO further writes.
|
|
exec(
|
|
&state,
|
|
r#"pmacs.config.set_local(TERM_A, "terminal.escape-key", "C-x")"#,
|
|
);
|
|
exec(
|
|
&state,
|
|
r#"pmacs.config.set_local(TERM_B, "terminal.escape-key", "C-b")"#,
|
|
);
|
|
|
|
// Prime both caches. Each priming press ARMS the escape, so it is
|
|
// consumed with a probe — otherwise the next chord would be read as
|
|
// the escape repeat rather than a fresh escape.
|
|
focus_terminal(&state, a);
|
|
state.dispatch_key(
|
|
FrontendId::LOCAL,
|
|
KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL),
|
|
);
|
|
assert!(escape_was_armed(&mut state, a, 'M'), "A primes on its C-x");
|
|
focus_terminal(&state, b);
|
|
state.dispatch_key(
|
|
FrontendId::LOCAL,
|
|
KeyEvent::new(KeyCode::Char('b'), KeyModifiers::CONTROL),
|
|
);
|
|
assert!(escape_was_armed(&mut state, b, 'N'), "B primes on its C-b");
|
|
let primed = state.terminal_manager.borrow().escape_parses();
|
|
assert_eq!(
|
|
state.terminal_manager.borrow().escape_caches(),
|
|
2,
|
|
"each primed terminal holds its own cache"
|
|
);
|
|
|
|
// Acceptance 7 — BOTH directions. Asserting only that A still works
|
|
// after A->B->A is not enough: an epoch-only cache hands whichever
|
|
// entry it finds to every terminal, so A keeps working by accident
|
|
// while B silently inherits A's chord. The discriminating assertion
|
|
// is that EACH terminal honors its OWN chord and NOT the other's.
|
|
focus_terminal(&state, b);
|
|
state.dispatch_key(
|
|
FrontendId::LOCAL,
|
|
KeyEvent::new(KeyCode::Char('b'), KeyModifiers::CONTROL),
|
|
);
|
|
assert!(
|
|
escape_was_armed(&mut state, b, 'R'),
|
|
"terminal B must escape on its own C-b"
|
|
);
|
|
// ...and A's chord must be ordinary input in B, not an escape.
|
|
focus_terminal(&state, b);
|
|
state.dispatch_key(
|
|
FrontendId::LOCAL,
|
|
KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL),
|
|
);
|
|
assert!(
|
|
!escape_was_armed(&mut state, b, 'S'),
|
|
"terminal A's C-x must NOT escape terminal B"
|
|
);
|
|
|
|
focus_terminal(&state, a);
|
|
state.dispatch_key(
|
|
FrontendId::LOCAL,
|
|
KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL),
|
|
);
|
|
assert!(
|
|
escape_was_armed(&mut state, a, 'Q'),
|
|
"terminal A must still escape on its own C-x after A->B->A"
|
|
);
|
|
|
|
// Acceptance 8: that round trip parsed nothing new. A single
|
|
// last-entry cache would have reparsed twice.
|
|
assert_eq!(
|
|
state.terminal_manager.borrow().escape_parses(),
|
|
primed,
|
|
"A->B->A with no setting written must not reparse"
|
|
);
|
|
|
|
// Acceptance 8a: the cache dies with its terminal.
|
|
//
|
|
// Waiting for the SESSION count to fall is not the assertion — a
|
|
// session set that drains while an editor-side `HashMap<BufferId,
|
|
// EscapeCache>` keeps its entry (the rejected implementation named
|
|
// in Q#TC4c, which has no purge hook) satisfies it exactly. The
|
|
// discriminating observable is the CACHE count, which such a map
|
|
// would hold at its high-water mark of 2.
|
|
let sessions_before = state.terminal_manager.borrow().len();
|
|
exec(&state, "pmacs.terminal.terminate(TERM_A)");
|
|
exec(&state, "pmacs.buffer.kill(TERM_A)");
|
|
// Pruning is tick-driven (the manager reaps on the process tick), so
|
|
// the session outlives the kill call by design.
|
|
let deadline = Instant::now() + Duration::from_secs(5);
|
|
while state.terminal_manager.borrow().len() >= sessions_before {
|
|
state.tick_processes();
|
|
assert!(
|
|
Instant::now() < deadline,
|
|
"killing the terminal must remove its session"
|
|
);
|
|
thread::sleep(Duration::from_millis(20));
|
|
}
|
|
assert_eq!(
|
|
state.terminal_manager.borrow().escape_caches(),
|
|
1,
|
|
"killing terminal A must drop ITS cache, not merely its session"
|
|
);
|
|
|
|
// ...and the surviving cache is B's, so the right one was dropped.
|
|
focus_terminal(&state, b);
|
|
state.dispatch_key(
|
|
FrontendId::LOCAL,
|
|
KeyEvent::new(KeyCode::Char('b'), KeyModifiers::CONTROL),
|
|
);
|
|
assert!(
|
|
escape_was_armed(&mut state, b, 'T'),
|
|
"terminal B must still escape on its own C-b after A was killed"
|
|
);
|
|
assert_eq!(
|
|
state.terminal_manager.borrow().escape_parses(),
|
|
primed,
|
|
"B's surviving cache must not have been reparsed"
|
|
);
|
|
state.process_supervisor.borrow_mut().shutdown();
|
|
}
|
|
|
|
/// Acceptance 10 and 10a: an unparseable value falls back, reports through
|
|
/// the status line, and reports once per terminal per effective bad value.
|
|
#[test]
|
|
fn acc10_acc10a_invalid_escape_falls_back_and_reports_once() {
|
|
let mut state = EditorState::new_with_roots(&crate::iso::roots());
|
|
exec(&state, CAT_PROFILE);
|
|
let buffer = open_cat_terminal(&state, r#"profile = "echo""#);
|
|
assert!(tick_until(&mut state, "READY", buffer));
|
|
focus_terminal(&state, buffer);
|
|
|
|
exec(
|
|
&state,
|
|
r#"pmacs.config.set("terminal.escape-key", "not-a-chord")"#,
|
|
);
|
|
state.core.borrow_mut().status.clear();
|
|
|
|
// Acceptance 10: falls back to C-c, so the terminal stays escapable.
|
|
state.dispatch_key(
|
|
FrontendId::LOCAL,
|
|
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
|
|
);
|
|
// Read the report BEFORE probing: `status` is a single slot, and the
|
|
// probe key's own rejected self-insert would overwrite it.
|
|
let reported = state.core.borrow().status.clone();
|
|
assert!(
|
|
reported.contains("terminal.escape-key") && reported.contains("not-a-chord"),
|
|
"the report must name the setting and the bad value: {reported:?}"
|
|
);
|
|
assert!(
|
|
escape_was_armed(&mut state, buffer, 'Q'),
|
|
"an invalid escape-key must fall back to C-c, not leave the \
|
|
terminal unescapable"
|
|
);
|
|
|
|
// Acceptance 10a: the same bad value does not report again.
|
|
state.core.borrow_mut().status.clear();
|
|
state.dispatch_key(
|
|
FrontendId::LOCAL,
|
|
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
|
|
);
|
|
assert!(
|
|
state.core.borrow().status.is_empty(),
|
|
"an unchanged invalid value must not re-report: {:?}",
|
|
state.core.borrow().status
|
|
);
|
|
let _ = escape_was_armed(&mut state, buffer, 'W');
|
|
|
|
// A DIFFERENT bad value is new information, so it reports again.
|
|
exec(
|
|
&state,
|
|
r#"pmacs.config.set("terminal.escape-key", "also-bad")"#,
|
|
);
|
|
state.core.borrow_mut().status.clear();
|
|
state.dispatch_key(
|
|
FrontendId::LOCAL,
|
|
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
|
|
);
|
|
assert!(
|
|
state.core.borrow().status.contains("also-bad"),
|
|
"a different invalid value must report: {:?}",
|
|
state.core.borrow().status
|
|
);
|
|
state.process_supervisor.borrow_mut().shutdown();
|
|
}
|
|
|
|
/// Acceptance 11: the opening binding exists, resolves to the command, and
|
|
/// shadowed nothing (`keymap.bind` is strict, so loading the runtime at all
|
|
/// proves the second half).
|
|
#[test]
|
|
fn acc11_terminal_opening_binding_is_bound_and_shadowed_nothing() {
|
|
let state = EditorState::new_with_roots(&crate::iso::roots());
|
|
let command: Option<String> = state
|
|
.lua_host
|
|
.lua()
|
|
.load(r#"local d = pmacs.describe.key("C-c t"); return d and d.command"#)
|
|
.eval()
|
|
.expect("describe.key");
|
|
assert_eq!(
|
|
command.as_deref(),
|
|
Some("terminal"),
|
|
"C-c t must open a terminal"
|
|
);
|
|
}
|
|
|
|
/// Acceptance 12: with no settings written and no profiles registered, the
|
|
/// defaults reproduce the pre-arc behavior.
|
|
#[test]
|
|
fn acc12_defaults_reproduce_prior_behavior() {
|
|
let state = EditorState::new_with_roots(&crate::iso::roots());
|
|
let lua = state.lua_host.lua();
|
|
assert_eq!(
|
|
lua.load(r#"return pmacs.config.get("terminal.default-profile")"#)
|
|
.eval::<String>()
|
|
.unwrap(),
|
|
""
|
|
);
|
|
assert_eq!(
|
|
lua.load(r#"return pmacs.config.get("terminal.scrollback-rows")"#)
|
|
.eval::<i64>()
|
|
.unwrap(),
|
|
10_000
|
|
);
|
|
assert_eq!(
|
|
lua.load(r#"return pmacs.config.get("terminal.escape-key")"#)
|
|
.eval::<String>()
|
|
.unwrap(),
|
|
"C-c"
|
|
);
|
|
assert!(
|
|
lua.load("return next(pmacs.terminal.profiles) == nil")
|
|
.eval::<bool>()
|
|
.unwrap(),
|
|
"no profiles are registered by default"
|
|
);
|
|
}
|
|
|
|
// Isolated bootstrap storage roots (see the module docs): an
|
|
// integration test is compiled without `cfg(test)`, so a raw
|
|
// `EditorState::new()` would read the developer's real `init.lua` and
|
|
// write into their real data root.
|
|
#[path = "common/iso.rs"]
|
|
mod iso;
|