460 lines
18 KiB
Rust
460 lines
18 KiB
Rust
// m6_4_repl_acceptance.rs --- T M6.4 acceptance gates.
|
|
|
|
//! Acceptance gates for T M6.4 (REPL view skeleton, Lua package).
|
|
//!
|
|
//! Spec §sec:repl-view defines four acceptance bullets:
|
|
//!
|
|
//! 1. Three-region structure renders correctly →
|
|
//! [`m6_4_three_regions_render_correctly`],
|
|
//! [`m6_4_appending_output_extends_history`],
|
|
//! [`m6_4_set_prompt_replaces_prompt_region`].
|
|
//! 2. Region boundaries update correctly when synthetic output is
|
|
//! appended → [`m6_4_appending_output_extends_history`].
|
|
//! 3. `intercept_edit` rejects edits in history and prompt regions →
|
|
//! [`m6_4_intercept_rejects_history_edits`],
|
|
//! [`m6_4_intercept_rejects_prompt_edits`],
|
|
//! [`m6_4_input_edits_pass_through`].
|
|
//! 4. Edits that span the input region boundary are truncated to the
|
|
//! input region →
|
|
//! [`m6_4_paste_spanning_input_boundary_truncated_to_input`],
|
|
//! [`m6_4_delete_spanning_input_boundary_truncated_to_input`].
|
|
//!
|
|
//! Plus regression tests for the M6.3 ↔ M6.4 boundary:
|
|
//!
|
|
//! - [`m6_4_ansi_styled_output_routes_to_history_only`][]: SGR-styled
|
|
//! output produces a clean rope (no escape bytes) and `SetStyle`
|
|
//! events on the side channel.
|
|
//! - [`m6_4_alt_screen_suppresses_history_writes`][]: a process
|
|
//! entering alt-screen does not write to history; exiting restores
|
|
//! normal flow.
|
|
//! - [`m6_4_submit_does_not_append_to_history`][]: locks in the
|
|
//! contract that the process echo (in M6.5) is the path that
|
|
//! appends user input to history, not `submit()` itself.
|
|
|
|
use pmacs::editor::EditorState;
|
|
use pmacs::lua_bindings::BufferIdLua;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Test harness
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Construct a fresh editor and run the given Lua chunk against it.
|
|
/// Lua-level `assert` failures surface as test failures; we don't
|
|
/// translate them into typed Rust errors because the chunk's
|
|
/// assertions are the test contract.
|
|
fn run(chunk: &str) {
|
|
let mut editor = EditorState::new();
|
|
editor
|
|
.lua_host
|
|
.eval(Some("@m6_4_test"), chunk)
|
|
.expect("test chunk runs");
|
|
}
|
|
|
|
/// Construct a fresh editor and return a captured value (for tests
|
|
/// that want to do final assertions on the Rust side).
|
|
fn run_returning<T: mlua::FromLuaMulti>(chunk: &str) -> T {
|
|
let mut editor = EditorState::new();
|
|
editor
|
|
.lua_host
|
|
.eval(Some("@m6_4_test"), chunk)
|
|
.and_then(|v| T::from_lua_multi(mlua::MultiValue::from_iter([v]), editor.lua_host.lua()))
|
|
.expect("test chunk runs and returns")
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Acceptance bullet 1: three-region structure
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn m6_4_three_regions_render_correctly() {
|
|
// Build a REPL, push some history, set a prompt, type input.
|
|
// The buffer should hold history then prompt then input, in
|
|
// order, with no escape bytes from the parser.
|
|
run(r#"
|
|
local h = pmacs.repl.create({ name = "*test*" })
|
|
h:append_output("welcome to pmacs\n")
|
|
h:set_prompt("$ ")
|
|
local buf = h:buffer_id()
|
|
buf:insert(h:prompt_end(), "echo hi")
|
|
|
|
-- Verify the regions:
|
|
-- [0, history_end) = "welcome to pmacs\n"
|
|
-- [history_end, prompt_end) = "$ "
|
|
-- [prompt_end, buf:len()) = "echo hi"
|
|
local history = buf:slice(0, h:history_end())
|
|
local prompt = buf:slice(h:history_end(), h:prompt_end())
|
|
local input = buf:slice(h:prompt_end(), buf:len())
|
|
assert(history == "welcome to pmacs\n",
|
|
"history: " .. history)
|
|
assert(prompt == "$ ", "prompt: " .. prompt)
|
|
assert(input == "echo hi", "input: " .. input)
|
|
"#);
|
|
}
|
|
|
|
#[test]
|
|
fn m6_4_appending_output_extends_history() {
|
|
// Each append moves history_end and prompt_end forward by the
|
|
// appended byte count. Input region (relative to prompt_end)
|
|
// remains intact.
|
|
run(r#"
|
|
local h = pmacs.repl.create({ name = "*test*" })
|
|
h:set_prompt("> ")
|
|
local buf = h:buffer_id()
|
|
buf:insert(h:prompt_end(), "ls -la")
|
|
local before_history = h:history_end()
|
|
local before_prompt = h:prompt_end()
|
|
local before_input = h:input_text()
|
|
|
|
h:append_output("first line\n")
|
|
assert(h:history_end() == before_history + #"first line\n",
|
|
"history_end shifted by appended length")
|
|
assert(h:prompt_end() == before_prompt + #"first line\n",
|
|
"prompt_end shifted by same amount")
|
|
assert(h:input_text() == before_input,
|
|
"input text preserved across append: '" .. h:input_text() .. "'")
|
|
|
|
h:append_output("second\n")
|
|
assert(h:history_end() == before_history + #"first line\n" + #"second\n",
|
|
"history_end shifted again")
|
|
assert(h:input_text() == "ls -la",
|
|
"input still typed text after second append")
|
|
"#);
|
|
}
|
|
|
|
#[test]
|
|
fn m6_4_set_prompt_replaces_prompt_region() {
|
|
// set_prompt swaps the bytes in [history_end, prompt_end) with
|
|
// the new prompt text. Old prompt text is gone from the buffer.
|
|
run(r#"
|
|
local h = pmacs.repl.create({ name = "*test*" })
|
|
h:append_output("hello\n")
|
|
h:set_prompt("user@host:~$ ")
|
|
local buf = h:buffer_id()
|
|
local content_first = buf:slice(0, buf:len())
|
|
assert(content_first == "hello\nuser@host:~$ ",
|
|
"first prompt set: " .. content_first)
|
|
|
|
h:set_prompt("> ")
|
|
local content_second = buf:slice(0, buf:len())
|
|
assert(content_second == "hello\n> ",
|
|
"second prompt replaced first: " .. content_second)
|
|
assert(not content_second:find("user@host"), "old prompt gone")
|
|
"#);
|
|
}
|
|
|
|
#[test]
|
|
fn m6_4_region_boundaries_are_mark_backed_across_input_edits() {
|
|
run(r#"
|
|
local h = pmacs.repl.create({ name = "*test*" })
|
|
h:append_output("history\n")
|
|
h:set_prompt("$ ")
|
|
local buf = h:buffer_id()
|
|
local prompt_before = h:prompt_end()
|
|
|
|
-- User edits at the input boundary must not drag the prompt
|
|
-- boundary forward. This is the failure mode byte-offset
|
|
-- mirrors hid before real marks existed.
|
|
buf:insert(prompt_before, "abc")
|
|
assert(h:prompt_end() == prompt_before,
|
|
"prompt_end moved across input insert")
|
|
assert(h:input_text() == "abc", "input text after insert")
|
|
|
|
h:append_output("more\n")
|
|
assert(h:input_text() == "abc",
|
|
"input preserved after output before prompt")
|
|
local history = buf:slice(0, h:history_end())
|
|
assert(history == "history\nmore\n",
|
|
"history after append: " .. history)
|
|
"#);
|
|
}
|
|
|
|
#[test]
|
|
fn m6_4_osc_133_prompt_markers_route_text_to_prompt_region() {
|
|
run(r#"
|
|
local h = pmacs.repl.create({ name = "*test*" })
|
|
h:append_output("\27]133;A\7$ \27]133;B\7")
|
|
local buf = h:buffer_id()
|
|
|
|
assert(h:history_end() == 0,
|
|
"prompt marker text must not enter history")
|
|
assert(buf:slice(h:history_end(), h:prompt_end()) == "$ ",
|
|
"prompt region should hold shell prompt")
|
|
|
|
buf:insert(h:prompt_end(), "typed")
|
|
h:append_output("out\n")
|
|
assert(buf:slice(0, h:history_end()) == "out\n",
|
|
"later output should enter history")
|
|
assert(buf:slice(h:history_end(), h:prompt_end()) == "$ ",
|
|
"prompt remains prompt after history output")
|
|
assert(h:input_text() == "typed", "input preserved")
|
|
"#);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Acceptance bullet 3: read-only enforcement
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn m6_4_intercept_rejects_history_edits() {
|
|
// An insert at any position < history_end raises. The buffer is
|
|
// unchanged.
|
|
run(r#"
|
|
local h = pmacs.repl.create({ name = "*test*" })
|
|
h:append_output("READONLY\n")
|
|
h:set_prompt("> ")
|
|
local buf = h:buffer_id()
|
|
local before = buf:slice(0, buf:len())
|
|
local ok, err = pcall(function()
|
|
buf:insert(0, "X") -- inside history region
|
|
end)
|
|
assert(not ok, "history insert must be rejected")
|
|
-- "read-only" contains a `-` which is a Lua-pattern magic
|
|
-- char; use the plain-string form of `find`.
|
|
assert(tostring(err):find("read-only", 1, true),
|
|
"error must name read-only: " .. tostring(err))
|
|
assert(buf:slice(0, buf:len()) == before,
|
|
"buffer unchanged after rejected edit")
|
|
"#);
|
|
}
|
|
|
|
#[test]
|
|
fn m6_4_intercept_rejects_prompt_edits() {
|
|
// Insert at a position in [history_end, prompt_end) is also
|
|
// rejected (prompt region is read-only).
|
|
run(r#"
|
|
local h = pmacs.repl.create({ name = "*test*" })
|
|
h:append_output("hi\n")
|
|
h:set_prompt("$$ ")
|
|
local buf = h:buffer_id()
|
|
local before = buf:slice(0, buf:len())
|
|
-- A position strictly inside the prompt region:
|
|
local mid_prompt = h:history_end() + 1
|
|
assert(mid_prompt < h:prompt_end(), "test geometry sanity")
|
|
local ok, err = pcall(function()
|
|
buf:insert(mid_prompt, "X")
|
|
end)
|
|
assert(not ok, "prompt insert must be rejected")
|
|
assert(buf:slice(0, buf:len()) == before, "buffer unchanged")
|
|
"#);
|
|
}
|
|
|
|
#[test]
|
|
fn m6_4_input_edits_pass_through() {
|
|
// Insert in [prompt_end, buf:len()] is permitted: the user is
|
|
// typing into the input region. Multiple inserts compose.
|
|
run(r#"
|
|
local h = pmacs.repl.create({ name = "*test*" })
|
|
h:set_prompt("> ")
|
|
local buf = h:buffer_id()
|
|
buf:insert(h:prompt_end(), "first")
|
|
buf:insert(buf:len(), " second")
|
|
assert(h:input_text() == "first second",
|
|
"input region accepts pass-through edits: " .. h:input_text())
|
|
"#);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Acceptance bullet 4: cross-boundary truncation
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn m6_4_paste_spanning_input_boundary_truncated_to_input() {
|
|
// A replace with start < prompt_end <= end is truncated: the
|
|
// range becomes [prompt_end, end). The replacement bytes pass
|
|
// through unchanged (per the M6.4 byte-immutability rule on
|
|
// LuaInterceptView).
|
|
run(r#"
|
|
local h = pmacs.repl.create({ name = "*test*" })
|
|
h:set_prompt("$ ")
|
|
local buf = h:buffer_id()
|
|
buf:insert(h:prompt_end(), "abcde") -- input region: "abcde"
|
|
|
|
-- Now paste 5 bytes spanning the prompt boundary by replacing
|
|
-- the last byte of the prompt and the first 3 bytes of input.
|
|
-- Original range: [prompt_end - 1, prompt_end + 3)
|
|
-- After truncate: [prompt_end, prompt_end + 3)
|
|
-- Replacement bytes: "WXYZQ" (5 bytes); they all land,
|
|
-- replacing the 3 input bytes "abc" → "WXYZQ".
|
|
local prompt_end_before = h:prompt_end()
|
|
buf:replace(prompt_end_before - 1, prompt_end_before + 3, "WXYZQ")
|
|
|
|
-- Prompt region must be intact.
|
|
local prompt_chars = buf:slice(h:history_end(), prompt_end_before)
|
|
assert(prompt_chars == "$ ",
|
|
"prompt region preserved: '" .. prompt_chars .. "'")
|
|
-- Input region is "WXYZQ" + "de" (the trailing input bytes
|
|
-- that were past the truncated range).
|
|
assert(h:input_text() == "WXYZQde",
|
|
"input region after truncated paste: '" .. h:input_text() .. "'")
|
|
"#);
|
|
}
|
|
|
|
#[test]
|
|
fn m6_4_delete_spanning_input_boundary_truncated_to_input() {
|
|
// A delete with start < prompt_end <= end is truncated to
|
|
// [prompt_end, end). No bytes are involved (Delete carries no
|
|
// bytes), so this is lossless.
|
|
run(r#"
|
|
local h = pmacs.repl.create({ name = "*test*" })
|
|
h:set_prompt("XX ") -- 3-byte prompt
|
|
local buf = h:buffer_id()
|
|
buf:insert(h:prompt_end(), "abcdef")
|
|
local prompt_end_before = h:prompt_end()
|
|
|
|
-- Delete spanning the boundary: [prompt_end - 2, prompt_end + 2).
|
|
-- Truncated to [prompt_end, prompt_end + 2): drops "ab".
|
|
buf:delete(prompt_end_before - 2, prompt_end_before + 2)
|
|
|
|
local prompt_chars = buf:slice(h:history_end(), prompt_end_before)
|
|
assert(prompt_chars == "XX ", "prompt preserved: '" .. prompt_chars .. "'")
|
|
assert(h:input_text() == "cdef",
|
|
"input after truncated delete: '" .. h:input_text() .. "'")
|
|
"#);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// M6.3 ↔ M6.4 boundary regressions
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn m6_4_ansi_styled_output_routes_to_history_only() {
|
|
// SGR-coded output flows through the parser. The rope receives
|
|
// only the literal text (no escape bytes). The set-style events
|
|
// are observed via the parser's event stream when feed runs;
|
|
// M6.4 captures the latest style on the handle (M6.5+ will
|
|
// render it). The check here is the parser-rope contract: the
|
|
// rope content matches the parser's literal-text emission.
|
|
run(r#"
|
|
local h = pmacs.repl.create({ name = "*test*" })
|
|
-- "\27[31mhello\27[0m world" → text "hello world", styles
|
|
-- (red set, default reset) on the side channel.
|
|
h:append_output("\27[31mhello\27[0m world")
|
|
local buf = h:buffer_id()
|
|
local content = buf:slice(0, buf:len())
|
|
assert(content == "hello world",
|
|
"rope holds only literal text, no escape bytes: '"
|
|
.. content .. "'")
|
|
assert(not content:find("\27"),
|
|
"rope must not contain ESC bytes")
|
|
local spans = h:style_spans()
|
|
assert(#spans == 1, "expected one red style span, got " .. #spans)
|
|
assert(spans[1].start == 0 and spans[1]["end"] == 5,
|
|
"red span should cover hello, got [" .. spans[1].start ..
|
|
"," .. spans[1]["end"] .. ")")
|
|
assert(spans[1].style.fg == 1,
|
|
"red span should carry palette fg=1")
|
|
"#);
|
|
}
|
|
|
|
#[test]
|
|
fn m6_4_line_level_ansi_updates_history_in_place() {
|
|
run(r#"
|
|
local h = pmacs.repl.create({ name = "*test*" })
|
|
h:append_output("progress 10%")
|
|
h:append_output("\rprogress 20%\27[K")
|
|
local content = h:buffer_id():slice(0, h:buffer_id():len())
|
|
assert(content == "progress 20%",
|
|
"CR overwrite + erase-to-EOL should update in place: '" ..
|
|
content .. "'")
|
|
|
|
h:append_output("\r\27[2Kdone\n")
|
|
content = h:buffer_id():slice(0, h:buffer_id():len())
|
|
assert(content == "done\n",
|
|
"erase-line should clear current line before rewrite: '" ..
|
|
content .. "'")
|
|
|
|
h:append_output("abc\bZ")
|
|
content = h:buffer_id():slice(0, h:buffer_id():len())
|
|
assert(content == "done\nabZ",
|
|
"backspace should rewind within current line: '" ..
|
|
content .. "'")
|
|
"#);
|
|
}
|
|
|
|
#[test]
|
|
fn m6_4_alt_screen_suppresses_history_writes() {
|
|
// Bytes between alt-screen-enter and alt-screen-exit do not land
|
|
// in history. Bytes before and after do. The handle exposes the
|
|
// alt-screen flag so packages can render an "alt-screen active"
|
|
// indicator (a M6.5 concern; we just lock in the flag's value).
|
|
run(r#"
|
|
local h = pmacs.repl.create({ name = "*test*" })
|
|
h:append_output("before-")
|
|
assert(not h:alt_screen_active(), "alt screen initially inactive")
|
|
h:append_output("\27[?1049h")
|
|
assert(h:alt_screen_active(), "alt screen active after enter")
|
|
h:append_output("INSIDE_ALT") -- suppressed by parser
|
|
h:append_output("\27[?1049l")
|
|
assert(not h:alt_screen_active(), "alt screen inactive after exit")
|
|
h:append_output("-after")
|
|
|
|
local content = h:buffer_id():slice(0, h:buffer_id():len())
|
|
assert(content == "before--after",
|
|
"alt-screen body suppressed: '" .. content .. "'")
|
|
assert(not content:find("INSIDE_ALT"),
|
|
"alt-screen body must not appear in history")
|
|
"#);
|
|
}
|
|
|
|
#[test]
|
|
fn m6_4_submit_does_not_append_to_history() {
|
|
// `submit()` returns the input region's text and clears it.
|
|
// The popped text must NOT land in history; M6.5 will let the
|
|
// shell echo the input back (in raw mode), and the parser will
|
|
// append the echo to history. Doing it in submit too would
|
|
// double the input. Lock that contract in here so M6.5 can wire
|
|
// submit's return to pmacs.process.write without any
|
|
// detect-and-suppress logic.
|
|
let final_state: String = run_returning(
|
|
r#"
|
|
local h = pmacs.repl.create({ name = "*test*" })
|
|
h:append_output("welcome\n")
|
|
h:set_prompt("> ")
|
|
local buf = h:buffer_id()
|
|
buf:insert(h:prompt_end(), "echo hi")
|
|
local popped = h:submit()
|
|
assert(popped == "echo hi",
|
|
"submit returns the input text: '" .. popped .. "'")
|
|
assert(h:input_text() == "",
|
|
"input region empty after submit: '" .. h:input_text() .. "'")
|
|
-- Critical: history did not grow by len("echo hi") just because
|
|
-- of submit. The buffer's content is exactly the prefix that
|
|
-- was there before the user started typing.
|
|
return buf:slice(0, buf:len())
|
|
"#,
|
|
);
|
|
assert_eq!(
|
|
final_state, "welcome\n> ",
|
|
"post-submit buffer must not contain submitted text in history"
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Sanity: handle types thread through Rust-side machinery
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn m6_4_buffer_id_is_a_real_buffer_handle() {
|
|
// The handle's `buffer_id()` returns a real BufferIdLua that
|
|
// resolves through the registry. This locks in that the package
|
|
// is built atop genuinely-public surface; no shadow APIs.
|
|
let mut editor = EditorState::new();
|
|
let returned: mlua::Value = editor
|
|
.lua_host
|
|
.eval(
|
|
Some("@m6_4_handle_test"),
|
|
r#"
|
|
local h = pmacs.repl.create({ name = "*handle*" })
|
|
return h:buffer_id()
|
|
"#,
|
|
)
|
|
.expect("eval");
|
|
let id_lua = match returned {
|
|
mlua::Value::UserData(ud) => *ud.borrow::<BufferIdLua>().expect("BufferIdLua userdata"),
|
|
other => panic!("expected BufferIdLua userdata, got {other:?}"),
|
|
};
|
|
let r = editor.lua_host.registry().borrow();
|
|
let buf = r.get(id_lua.id()).expect("registered");
|
|
assert_eq!(buf.name(), "*handle*");
|
|
}
|