pmacs/tests/terminal_config_acceptance.rs

747 lines
26 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();
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();
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();
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();
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();
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();
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();
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();
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();
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();
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();
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();
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"
);
}