// m6_7_scrollback_acceptance.rs --- T M6.7 acceptance gates. //! Acceptance gates for T M6.7 (Scrollback management). //! //! Spec §sec:repl-perf defines three gates: //! //! 1. Navigation latency p99 ≤ 16 ms across 10000-line scrollback. //! 2. Search latency p99 ≤ 100 ms across 10000 lines. //! 3. Truncation under memory pressure preserves logical region //! boundaries (no partial command-output blocks; no input-region //! truncation). //! //! Gates 1 and 2 are perf gates and live in //! [`tests/m6_perf_acceptance.rs`] under the `#[ignore]` / //! `--release` pattern locked by M5.9c. This file covers gate 3 (a //! correctness test, not a perf test) plus the lib-level invariants //! that anchor it: pre-first-submit block, no zero-length blocks on //! submit, single-pass truncation, and rope/_blocks consistency. //! //! Test process choice: the Lua REPL (mlua's interpreter) is the //! deterministic stand-in. Bash/zsh/fish prompts vary; the lua REPL //! prints predictable text and is always available because lua is //! a build dep. We don't actually need a process for the //! invariant tests --- `pmacs.repl.create` + synthetic //! `append_output` exercises the same scrollback path. use pmacs::editor::EditorState; // --------------------------------------------------------------------------- // Harness // --------------------------------------------------------------------------- fn run(chunk: &str) { let mut editor = EditorState::new(); editor .lua_host .eval(Some("@m6_7_test"), chunk) .expect("test chunk runs"); } // --------------------------------------------------------------------------- // Block-tracking invariants // --------------------------------------------------------------------------- #[test] fn m6_7_pre_first_submit_block_exists_with_start_byte_zero() { // The first block is real and degenerate: it covers any bytes // received before the first user submit. Without this, a process // that produces a long preamble before its first prompt has no // block boundary to use as a truncation point. run(r#" local h = pmacs.repl.create({ name = "*test*" }) assert(#h._blocks == 1, "expected 1 block, got " .. #h._blocks) assert(h._blocks[1].start_byte == 0, "expected start_byte 0, got " .. h._blocks[1].start_byte) "#); } #[test] fn m6_7_emit_history_extends_active_block() { // Bytes flowing through append_output extend the active block's // span by extending _history_end (which acts as the block-end // for the active block). The pre-first-submit block is active // until the first submit. run(r#" local h = pmacs.repl.create({ name = "*test*" }) h:append_output("line one\nline two\nline three\n") assert(#h._blocks == 1, "still one block") assert(h._blocks[1].start_byte == 0, "block 1 start_byte should be 0") assert(h._history_end == #"line one\nline two\nline three\n", "history_end mismatch: " .. h._history_end) "#); } #[test] fn m6_7_submit_opens_new_block_when_active_has_bytes() { // The submit boundary is what gives us a "complete command-output // block" to truncate. After the first submit (with bytes already // in history), block 2 opens at the current history_end. run(r#" local h = pmacs.repl.create({ name = "*test*" }) h:append_output("preamble\n") h:set_prompt("$ ") local buf = h:buffer_id() buf:insert(h:prompt_end(), "echo hi") local _ = h:submit() assert(#h._blocks == 2, "expected 2 blocks, got " .. #h._blocks) assert(h._blocks[2].start_byte == h._history_end, "block 2 should start at history_end") "#); } #[test] fn m6_7_submit_with_no_history_bytes_does_not_open_block() { // Repeated submits before any output land has no boundary to // record. Adding zero-length blocks would violate the strictly- // increasing start_byte invariant; the right shape is to keep // the active block until it accumulates bytes. run(r#" local h = pmacs.repl.create({ name = "*test*" }) h:set_prompt("$ ") h:submit() h:submit() h:submit() assert(#h._blocks == 1, "expected 1 block after empty submits, got " .. #h._blocks) assert(h._blocks[1].start_byte == 0) "#); } #[test] fn m6_7_blocks_strictly_increasing_after_mixed_submits() { // Strictly-increasing invariant under a realistic interleave: // each submit either opens a block (when bytes accumulated) or // doesn't (when none did). The Lua chunk does the assertions. run(r#" local h = pmacs.repl.create({ name = "*test*" }) h:append_output("welcome\n") h:set_prompt("$ ") h:submit() -- opens block 2 h:submit() -- no-op; block 2 is empty h:append_output("output a\n") -- accumulates in block 2 h:submit() -- opens block 3 h:append_output("output b\n") -- accumulates in block 3 h:submit() -- opens block 4 assert(#h._blocks == 4, "expected 4 blocks, got " .. #h._blocks) for i = 1, #h._blocks - 1 do assert(h._blocks[i].start_byte < h._blocks[i + 1].start_byte, "blocks not strictly increasing at i=" .. i) end "#); } // --------------------------------------------------------------------------- // Truncation invariants // --------------------------------------------------------------------------- #[test] fn m6_7_truncate_drops_oldest_block_when_line_limit_exceeded() { // With scrollback_lines = 2 and three populated blocks of one // line each, _maybe_truncate should drop block 1 (and possibly // block 2 depending on whether the active block counts toward // the limit). Verify the line count drops and the buffer shrinks // by the corresponding bytes. run(r#" local function count_newlines(s) local n, i = 0, 0 while true do i = s:find("\n", i + 1, true) if not i then return n end n = n + 1 end end pmacs.repl.config.scrollback_lines = 2 pmacs.repl.config.scrollback_bytes = 1024 * 1024 * 1024 local h = pmacs.repl.create({ name = "*test*" }) h:append_output("aaaa\n") h:set_prompt("$ ") h:submit() h:append_output("bbbb\n") h:submit() h:append_output("cccc\n") local pre_lines = count_newlines(h:buffer_id():slice(0, h._history_end)) assert(pre_lines == 3, "pre-truncate lines: " .. pre_lines) h:_maybe_truncate() local post_lines = count_newlines(h:buffer_id():slice(0, h._history_end)) assert(post_lines <= 2, "post-truncate lines should be <= 2, got " .. post_lines) assert(h._blocks[1].start_byte == 0, "block 1 start_byte should be 0 after normalize, got " .. h._blocks[1].start_byte) pmacs.repl.config.scrollback_lines = 10000 "#); } #[test] fn m6_7_truncate_drops_oldest_block_when_byte_limit_exceeded() { // With scrollback_bytes set tight, byte invariant fires even when // the line invariant holds. run(r#" pmacs.repl.config.scrollback_lines = 1000000 pmacs.repl.config.scrollback_bytes = 12 -- 12 bytes local h = pmacs.repl.create({ name = "*test*" }) h:append_output("aaaaa\n") -- block 1: 6 bytes h:set_prompt("$ ") h:submit() h:append_output("bbbbb\n") -- block 2: 6 bytes h:submit() h:append_output("cccc\n") -- block 3 (active): 5 bytes; total 17 assert(h._history_end == 17, "pre-truncate end: " .. h._history_end) h:_maybe_truncate() assert(h._history_end <= 12, "post-truncate end should be <= 12, got " .. h._history_end) assert(h._blocks[1].start_byte == 0) pmacs.repl.config.scrollback_bytes = 16 * 1024 * 1024 "#); } #[test] fn m6_7_truncate_never_removes_active_block() { // Even with both limits set absurdly tight, the active (last) // block is preserved. Truncation cannot leave a handle with no // block to accumulate into. run(r#" pmacs.repl.config.scrollback_lines = 0 pmacs.repl.config.scrollback_bytes = 0 local h = pmacs.repl.create({ name = "*test*" }) h:append_output("only\n") -- single block, active h:_maybe_truncate() assert(#h._blocks == 1, "active block must survive, got " .. #h._blocks .. " blocks") pmacs.repl.config.scrollback_lines = 10000 pmacs.repl.config.scrollback_bytes = 16 * 1024 * 1024 "#); } #[test] fn m6_7_truncate_preserves_input_region() { // The user's in-flight input (past prompt_end) is the load-bearing // safety property: losing output is annoying, losing what the user // is typing is unacceptable. Even aggressive truncation leaves // input bytes intact. run(r#" pmacs.repl.config.scrollback_lines = 1 local h = pmacs.repl.create({ name = "*test*" }) h:append_output("history line\n") h:set_prompt("$ ") h:submit() h:append_output("more history\n") local buf = h:buffer_id() buf:insert(buf:len(), "user typing") local pre_input = buf:slice(h:prompt_end(), buf:len()) assert(pre_input == "user typing") h:_maybe_truncate() local post_input = buf:slice(h:prompt_end(), buf:len()) assert(post_input == "user typing", "input region truncated: " .. post_input) pmacs.repl.config.scrollback_lines = 10000 "#); } #[test] fn m6_7_truncate_keeps_rope_and_blocks_consistent() { // After truncation, every block.start_byte is a valid index in // the rope, the active block's start_byte is at most history_end, // and the array is strictly increasing. run(r#" pmacs.repl.config.scrollback_lines = 2 local h = pmacs.repl.create({ name = "*test*" }) for i = 1, 5 do h:append_output("line " .. i .. "\n") h:set_prompt("$ ") h:submit() end h:_maybe_truncate() for i, b in ipairs(h._blocks) do assert(b.start_byte >= 0 and b.start_byte <= h._history_end, "block " .. i .. " start_byte " .. b.start_byte .. " out of range [0, " .. h._history_end .. "]") end assert(h._blocks[1].start_byte == 0) for i = 1, #h._blocks - 1 do assert(h._blocks[i].start_byte < h._blocks[i + 1].start_byte, "blocks not strictly increasing") end pmacs.repl.config.scrollback_lines = 10000 "#); } // --------------------------------------------------------------------------- // Spec gate: 50 truncation events with varied retention // --------------------------------------------------------------------------- #[test] fn m6_7_50_truncation_events_preserve_block_boundaries() { // The spec gate: 50 truncation events under varied retention // values, asserting the four invariants on each event: // (a) first block start_byte == 0 // (b) blocks strictly increasing // (c) input region not truncated // (d) rope and blocks consistent // // Each event is one truncation pass with a distinct retention // value. Varying retention sweeps through the parameter space: // tight line limits (forces line-driven truncation), tight byte // limits (forces byte-driven truncation), and mixed cases where // both fire. run(r#" local function check_invariants(h, label) assert(#h._blocks >= 1, label .. ": at least one block") assert(h._blocks[1].start_byte == 0, label .. ": block 1 start_byte == 0") for i = 1, #h._blocks - 1 do assert(h._blocks[i].start_byte < h._blocks[i + 1].start_byte, label .. ": blocks strictly increasing at i=" .. i) end for i, b in ipairs(h._blocks) do assert(b.start_byte >= 0 and b.start_byte <= h._history_end, label .. ": block " .. i .. " start_byte out of range") end end for trial = 1, 50 do -- Vary retention: trial 1..25 sweep line limits 1..25; -- trial 26..50 sweep byte limits 8..208 (8-byte stride). if trial <= 25 then pmacs.repl.config.scrollback_lines = trial pmacs.repl.config.scrollback_bytes = 16 * 1024 * 1024 else pmacs.repl.config.scrollback_lines = 10000 pmacs.repl.config.scrollback_bytes = 8 + (trial - 26) * 8 end local h = pmacs.repl.create({ name = "*trial-" .. trial .. "*" }) -- Populate ~30 blocks of varying size so truncation has -- meaningful work to do. for i = 1, 30 do h:append_output("block-" .. i .. "-output\n") h:set_prompt("$ ") h:submit() end -- Add some user-typed input that must survive truncation. local buf = h:buffer_id() local marker = "USER-INPUT-" .. trial buf:insert(buf:len(), marker) local pre_input = buf:slice(h:prompt_end(), buf:len()) assert(pre_input == marker, "trial " .. trial .. ": pre_input setup") h:_maybe_truncate() check_invariants(h, "trial " .. trial) local post_input = buf:slice(h:prompt_end(), buf:len()) assert(post_input == marker, "trial " .. trial .. ": input region truncated; got " .. post_input) end pmacs.repl.config.scrollback_lines = 10000 pmacs.repl.config.scrollback_bytes = 16 * 1024 * 1024 "#); }