//! Compile-mode acceptance (Arc 5 stage 1, //! docs/compile-mode-framing.md, items 1–33; item 34 lives as unit //! tests in src/process.rs, item 35 in //! `tests/compile_mode_crdt_acceptance.rs`). //! //! Dispatch-driven: every keybinding claim is exercised through //! `dispatch_key` (never `pmacs.command.invoke`), per the standing //! discipline — a dead binding must fail these tests. Process output //! is pumped through `tick_processes` (the production //! `process.after-tick` path); grep streams through `tick_async`. //! Fixtures are `/bin/sh` scripts materialized in tempdirs. use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers}; use pmacs::editor::EditorState; use pmacs::protocol::FrontendId; use std::path::Path; use std::time::{Duration, Instant}; // --------------------------------------------------------------------------- // Harness (auto_pair_acceptance conventions + m6_5 pump pattern) // --------------------------------------------------------------------------- fn key(code: KeyCode, mods: KeyModifiers) -> KeyEvent { KeyEvent { code, modifiers: mods, kind: KeyEventKind::Press, state: KeyEventState::NONE, } } fn ctrl(s: &mut EditorState, c: char) { s.dispatch_key( FrontendId::LOCAL, key(KeyCode::Char(c), KeyModifiers::CONTROL), ); } fn ctrl_shift(s: &mut EditorState, c: char) { s.dispatch_key( FrontendId::LOCAL, key( KeyCode::Char(c), KeyModifiers::CONTROL | KeyModifiers::SHIFT, ), ); } fn alt(s: &mut EditorState, c: char) { s.dispatch_key(FrontendId::LOCAL, key(KeyCode::Char(c), KeyModifiers::ALT)); } fn press(s: &mut EditorState, code: KeyCode) { s.dispatch_key(FrontendId::LOCAL, key(code, KeyModifiers::NONE)); } fn type_str(s: &mut EditorState, text: &str) { for ch in text.chars() { s.dispatch_key( FrontendId::LOCAL, key(KeyCode::Char(ch), KeyModifiers::NONE), ); } } fn exec(s: &EditorState, src: &str) { s.lua_host.lua().load(src.to_string()).exec().unwrap(); } fn eval(s: &EditorState, src: &str) -> T { s.lua_host.lua().load(src.to_string()).eval().unwrap() } fn status(s: &EditorState) -> String { s.core.borrow().status.clone() } fn errors_buffer(s: &EditorState) -> String { s.lua_host.errors_buffer_text() } /// Fresh editor with LSP spawning disabled (language detection still /// works; the after-load hook must not exec real servers). fn editor() -> EditorState { let s = EditorState::new(); exec(&s, "pmacs.lsp.config = {}"); s } fn write_script(dir: &Path, name: &str, body: &str) -> String { let p = dir.join(name); std::fs::write(&p, body).unwrap(); p.display().to_string() } /// Text of the buffer named `name`, or empty when absent. fn named_text(s: &EditorState, name: &str) -> String { let b: mlua::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 "" "# ), ); String::from_utf8_lossy(&b.as_bytes()).into_owned() } fn active_buffer_name(s: &EditorState) -> String { eval( s, "return pmacs.describe.buffer(pmacs.window.buffer()).name", ) } fn compilation_text(s: &EditorState) -> String { named_text(s, "*compilation*") } fn process_count(s: &EditorState) -> i64 { eval(s, "return #pmacs.process.list()") } /// Drive frames until `pred` holds. Pumps both the process /// supervisor (compile/shell) and the async runtime (grep workers). fn pump_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)); } } /// Start a compile run programmatically with an explicit cwd. fn compile_run(s: &EditorState, cmdline: &str, cwd: &Path) { exec( s, &format!( "pmacs.compile.run({cmdline:?}, {{ cwd = {:?} }})", cwd.display().to_string() ), ); } /// Run `cmdline` and pump to its exit marker. Panics on timeout. fn compile_and_finish(s: &mut EditorState, cmdline: &str, cwd: &Path) { compile_run(s, cmdline, cwd); assert!( pump_until(s, 10_000, |s| compilation_text(s).contains("[compile ")), "compile run must reach its exit marker; buffer:\n{}", compilation_text(s) ); } /// Poll `path` for a pid the fixture script wrote there. fn wait_pidfile(s: &mut EditorState, path: &Path) -> i32 { let mut pid = None; pump_until(s, 5_000, |_| { if let Ok(body) = std::fs::read_to_string(path) && let Ok(p) = body.trim().parse::() { pid = Some(p); return true; } false }); pid.expect("fixture pidfile must appear") } fn pid_alive(pid: i32) -> bool { Path::new(&format!("/proc/{pid}")).exists() } /// Errors getter marshalled to a comparable Rust shape (encoded as /// one line per entry — mlua tuples don't implement `FromLua`). fn compile_errors(s: &EditorState) -> Vec<(String, i64, i64, Option)> { let encoded: String = eval( s, r#" local out = {} for _, e in ipairs(pmacs.compile.errors()) do out[#out + 1] = string.format("%s|%d|%d|%s", e.file, e.line, e.col, e.severity or "-") end return table.concat(out, "\n") "#, ); encoded .lines() .map(|l| { let mut parts = l.split('|'); let file = parts.next().unwrap().to_owned(); let line = parts.next().unwrap().parse().unwrap(); let col = parts.next().unwrap().parse().unwrap(); let sev = match parts.next().unwrap() { "-" => None, s => Some(s.to_owned()), }; (file, line, col, sev) }) .collect() } /// Rendered cells of the active window (copied from the /// `m4_acceptance` grid helper — cross-crate test code can't import). fn render_active_window_to_grid( state: &mut EditorState, rows: u32, cols: u32, ) -> Vec { use pmacs::cell::{Cell, CellGrid, CellSize}; use pmacs::view::{View, Viewport}; use pmacs::window::Rect; let mut core = state.core.borrow_mut(); let active = core.active_window_id(); let registry = core.registry.clone(); let win = core.windows.get_mut(&active).expect("active window"); let rect = Rect::new(0, 0, rows, cols); let cell_count = (rect.size.rows * rect.size.cols) as usize; let mut backing = vec![Cell::default(); cell_count]; let reg = registry.borrow(); let buf = reg.get(win.buffer_id).expect("buffer in registry"); let viewport = Viewport { buffer_start: 0, buffer_end: buf.len(), cell_origin: rect.origin, cell_size: CellSize::new(rect.size.rows, rect.size.cols), gutter_w: 0, }; let mut grid = CellGrid { cells: &mut backing, stride: rect.size.cols, size: CellSize::new(rect.size.rows, rect.size.cols), }; win.text_view.render(buf, viewport, &mut grid); for overlay in &mut win.overlays { overlay.render(buf, viewport, &mut grid); } backing } fn any_styled_cell(cells: &[pmacs::cell::Cell]) -> bool { cells .iter() .any(|c| c.style != pmacs::cell::Style::default()) } const DESYNC: &str = "[output desynced by external edit]"; // --------------------------------------------------------------------------- // 1–4: spawn shape, read-only, merged interleaving, EOF // --------------------------------------------------------------------------- #[test] fn acc01_spawn_streams_header_output_and_exit_marker() { let dir = tempfile::tempdir().unwrap(); let mut s = editor(); compile_and_finish(&mut s, "printf 'hello\\nworld\\n'", dir.path()); let text = compilation_text(&s); assert!( text.starts_with("$ printf"), "header leads with the command; got:\n{text}" ); assert!( text.contains(&format!("Directory: {}", dir.path().display())), "header names the resolved cwd; got:\n{text}" ); assert!(text.contains("hello\nworld\n"), "output streamed:\n{text}"); assert!( text.contains("[compile exited with code 0]"), "exit marker with code:\n{text}" ); assert!( status(&s).contains("finished"), "completion status; got: {}", status(&s) ); assert_eq!(active_buffer_name(&s), "*compilation*", "switch-in-place"); } #[test] fn acc02_buffer_is_read_only_under_dispatch() { let dir = tempfile::tempdir().unwrap(); let mut s = editor(); compile_and_finish(&mut s, "echo out", dir.path()); let before = compilation_text(&s); type_str(&mut s, "x"); assert_eq!( compilation_text(&s), before, "dispatched typing must be rejected by the read-only intercept" ); } #[test] fn acc03_stderr_interleaves_in_emission_order() { let dir = tempfile::tempdir().unwrap(); let script = write_script( dir.path(), "mix.sh", "echo out1\necho err1 >&2\necho out2\necho err2 >&2\n", ); let mut s = editor(); compile_and_finish(&mut s, &format!("sh {script}"), dir.path()); let text = compilation_text(&s); assert!( text.contains("out1\nerr1\nout2\nerr2\n"), "child-boundary merge preserves emission order (per-tick \ stdout-then-stderr coalescing would reorder); got:\n{text}" ); } #[test] fn acc04_stdin_eof_lets_cat_terminate() { let dir = tempfile::tempdir().unwrap(); let mut s = editor(); let t0 = Instant::now(); compile_and_finish(&mut s, "cat; echo done", dir.path()); assert!( compilation_text(&s).contains("\ndone\n"), "cat must see EOF and fall through (line-start match — the \ header echoes the command and would match bare 'done')" ); assert!( compilation_text(&s).contains("exited with code 0"), "clean exit" ); assert!( t0.elapsed() < Duration::from_secs(5), "must not hang on piped stdin" ); } // --------------------------------------------------------------------------- // 5–9: group lifecycle through the editor surface // --------------------------------------------------------------------------- #[test] fn acc05_kill_reaps_backgrounded_descendant() { let dir = tempfile::tempdir().unwrap(); let pidfile = dir.path().join("pid"); let mut s = editor(); compile_run( &s, &format!("sleep 30 & echo $! > {}; wait", pidfile.display()), dir.path(), ); let pid = wait_pidfile(&mut s, &pidfile); ctrl(&mut s, 'c'); ctrl(&mut s, 'k'); assert!( pump_until(&mut s, 10_000, |s| compilation_text(s) .contains("[compile killed by")), "kill must produce a signaled exit marker; buffer:\n{}", compilation_text(&s) ); assert!( pump_until(&mut s, 3_000, |_| !pid_alive(pid)), "group-directed kill must reap the backgrounded descendant \ (positive-pid SIGTERM would strand it)" ); assert!( pump_until(&mut s, 3_000, |s| process_count(s) == 0), "process list returns to baseline" ); } #[test] fn acc06_leader_exit_without_wait_completes_promptly_and_reaps() { let dir = tempfile::tempdir().unwrap(); let pidfile = dir.path().join("pid"); let mut s = editor(); let t0 = Instant::now(); compile_run( &s, &format!("sleep 30 & echo $! > {}", pidfile.display()), dir.path(), ); assert!( pump_until(&mut s, 5_000, |s| compilation_text(s) .contains("[compile exited")), "leader exit must be observed without waiting on the descendant" ); assert!( t0.elapsed() < Duration::from_millis(2500), "the run must not ride the 2s drain timeout; took {:?}", t0.elapsed() ); let pid = wait_pidfile(&mut s, &pidfile); assert!( pump_until(&mut s, 3_000, |_| !pid_alive(pid)), "leader-exit reap must kill the pipe-holding descendant" ); assert!( pump_until(&mut s, 3_000, |s| process_count(s) == 0), "process list returns to baseline" ); } #[test] fn acc07_term_trapping_child_falls_to_sigkill() { let dir = tempfile::tempdir().unwrap(); let mut s = editor(); compile_run(&s, "trap '' TERM; echo ready; sleep 30", dir.path()); // "\nready\n" — the OUTPUT line, not the header's echo of the // command (matching the header raced the kill ahead of the trap // installation and let plain SIGTERM win). assert!( pump_until(&mut s, 5_000, |s| compilation_text(s).contains("\nready\n")), "trap must be installed before we kill" ); let t0 = Instant::now(); ctrl(&mut s, 'c'); ctrl(&mut s, 'k'); assert!( pump_until(&mut s, 5_000, |s| compilation_text(s) .contains("killed by SIGKILL")), "TERM-trapping child must fall to the ledger's SIGKILL; buffer:\n{}", compilation_text(&s) ); assert!( t0.elapsed() < Duration::from_secs(2), "escalation lands near the 500ms grace, not the drain timeout; took {:?}", t0.elapsed() ); assert!( pump_until(&mut s, 3_000, |s| process_count(s) == 0), "baseline restored" ); } #[test] fn acc08_ledger_reaps_term_ignoring_redirected_survivor() { // The bite: the survivor ignores TERM and sheds its output, so // the terminal event arrives AND the readers finish — only the // liveness probe can catch it. let dir = tempfile::tempdir().unwrap(); let pidfile = dir.path().join("pid"); let mut s = editor(); compile_run( &s, &format!( "( trap '' TERM; exec >/dev/null 2>&1; sleep 30 ) & echo $! > {}", pidfile.display() ), dir.path(), ); assert!( pump_until(&mut s, 5_000, |s| compilation_text(s) .contains("exited with code 0")), "leader exits cleanly" ); let pid = wait_pidfile(&mut s, &pidfile); assert!( pump_until(&mut s, 3_000, |_| !pid_alive(pid)), "the kill(-pgid, 0) probe must reap the redirected survivor \ (leader- or reader-conditioned escalation never fires here)" ); } #[test] fn acc09_pipe_holding_survivor_bounded_tick_latency() { // Non-redirected twin of acc08: the descendant KEEPS fd1, so the // readers stay alive — in-drain ledger enforcement must SIGKILL // at the grace bound instead of blocking ~2s. let dir = tempfile::tempdir().unwrap(); let mut s = editor(); compile_run(&s, "( trap '' TERM; sleep 30 ) & echo started", dir.path()); let stop = Instant::now() + Duration::from_secs(6); let mut max_tick = Duration::ZERO; let mut done = false; while Instant::now() < stop { let t = Instant::now(); s.tick_processes(); max_tick = max_tick.max(t.elapsed()); if compilation_text(&s).contains("[compile exited") { done = true; break; } std::thread::sleep(Duration::from_millis(5)); } assert!(done, "run must complete; buffer:\n{}", compilation_text(&s)); assert!( max_tick < Duration::from_millis(1200), "blocking tick bounded by ~grace + 2 poll intervals, not the \ 2s drain timeout; max tick {max_tick:?}" ); } // --------------------------------------------------------------------------- // 10–14: error parsing // --------------------------------------------------------------------------- #[test] fn acc10_starter_rules_parse_and_normalize() { let dir = tempfile::tempdir().unwrap(); let script = write_script( dir.path(), "diag.sh", concat!( "printf 'error[E0308]: mismatched types\\n'\n", "printf ' --> src/foo.rs:3:5\\n'\n", "printf 'foo.c:7:2: warning: unused variable\\n'\n", "printf 'Traceback (most recent call last):\\n'\n", "printf ' File \"bar.py\", line 9\\n'\n", ), ); let mut s = editor(); compile_and_finish(&mut s, &format!("sh {script}"), dir.path()); let errors = compile_errors(&s); assert_eq!( errors, vec![ // rustc arrow: 1-based 3:5 → 0-based (2,4); the arrow // line carries no severity token → nil (navigable but // uncolored, per Q#CM4). ("src/foo.rs".to_owned(), 2, 4, None), // gcc-style colocates the keyword → sniffed severity. ("foo.c".to_owned(), 6, 1, Some("warning".to_owned())), // Python frame: no column → col 0; no severity token. ("bar.py".to_owned(), 8, 0, None), ], "starter-rule parse + 0-based normalization" ); } #[test] fn acc11_sub_one_coordinates_fail_closed() { let dir = tempfile::tempdir().unwrap(); let mut s = editor(); compile_and_finish(&mut s, "printf 'foo.rs:0:0: error: boom\\n'", dir.path()); assert!( compile_errors(&s).is_empty(), "a 0:0 capture must be discarded, not stored as -1; got {:?}", compile_errors(&s) ); } #[test] fn acc12_custom_rule_severity_override_and_malformed_rejection() { let dir = tempfile::tempdir().unwrap(); let mut s = editor(); exec( &s, r#" pmacs.compile.rules = { { pattern = "x", file = 1, line = 1, severity = "fatal" }, { pattern = "(z%.txt):(%d+):", file = 1, line = 2, severity = "warning" }, } "#, ); // The skip note is a transient status set at run start; capture // it before the completion status overwrites it. compile_run(&s, "printf 'z.txt:5: error: boom\\n'", dir.path()); assert!( status(&s).contains("skipped 1 malformed"), "the severity=\"fatal\" entry is rejected and counted; got: {}", status(&s) ); assert!(pump_until(&mut s, 10_000, |s| compilation_text(s) .contains("[compile exited"))); let errors = compile_errors(&s); assert_eq!( errors, vec![("z.txt".to_owned(), 4, 0, Some("warning".to_owned()))], "the severity field overrides the sniffed 'error' keyword" ); } #[test] fn acc13_unterminated_final_line_still_parses() { let dir = tempfile::tempdir().unwrap(); let mut s = editor(); // No trailing newline: complete at EOF, parsed at the terminal // event (bite: fails without the finalization pass). compile_and_finish(&mut s, "printf 'x.c:3:1: error: no newline'", dir.path()); assert_eq!( compile_errors(&s), vec![("x.c".to_owned(), 2, 0, Some("error".to_owned()))], "final unterminated diagnostic must not be dropped" ); } #[test] fn acc14_malformed_rule_containers_fail_closed() { let dir = tempfile::tempdir().unwrap(); // (a) top-level non-table degrades to the built-in defaults. let mut s = editor(); exec(&s, "pmacs.compile.rules = 42"); compile_run(&s, "printf 'a.c:1:1: error: e\\n'", dir.path()); assert!( status(&s).contains("not a table"), "one degradation note at run start; got: {}", status(&s) ); assert!(pump_until(&mut s, 10_000, |s| compilation_text(s) .contains("[compile exited"))); assert_eq!( compile_errors(&s).len(), 1, "built-in defaults still parse under a non-table container" ); assert!( errors_buffer(&s).is_empty(), "no error spam: {}", errors_buffer(&s) ); // (b) an invalid-pattern entry is skipped; a later valid entry // still matches; one status note counts the skip. let mut s = editor(); exec( &s, r#" pmacs.compile.rules = { { pattern = "([", file = 1, line = 2 }, { pattern = "(b%.c):(%d+):", file = 1, line = 2 }, } "#, ); compile_run(&s, "printf 'b.c:4: error: e\\n'", dir.path()); assert!( status(&s).contains("skipped 1 malformed"), "one status note at run start; got: {}", status(&s) ); assert!(pump_until(&mut s, 10_000, |s| compilation_text(s) .contains("[compile exited"))); assert_eq!( compile_errors(&s), vec![("b.c".to_owned(), 3, 0, Some("error".to_owned()))], "the valid entry still matches after the malformed one" ); assert!( errors_buffer(&s).is_empty(), "no error spam: {}", errors_buffer(&s) ); } // --------------------------------------------------------------------------- // 15–18: navigation // --------------------------------------------------------------------------- /// Fixture: a target file plus a compile run reporting one error at /// target.c:3:2. Returns the editor, finished, in *compilation*. fn error_fixture(dir: &Path) -> EditorState { std::fs::write(dir.join("target.c"), "l1\nl2\nl3 body\nl4\n").unwrap(); let mut s = editor(); compile_and_finish(&mut s, "printf 'target.c:3:2: error: boom\\n'", dir); s } #[test] fn acc15_ret_visits_error_and_jump_back_returns() { let dir = tempfile::tempdir().unwrap(); let mut s = error_fixture(dir.path()); // Cursor starts on the header (row 0): RET there reports and // stays. press(&mut s, KeyCode::Enter); assert_eq!(status(&s), "no error on this line"); assert_eq!(active_buffer_name(&s), "*compilation*"); // n lands on the diagnostic row; RET visits at 0-based (2,1). press(&mut s, KeyCode::Char('n')); press(&mut s, KeyCode::Enter); assert!( active_buffer_name(&s).ends_with("target.c"), "RET visits the file; active: {}", active_buffer_name(&s) ); let line: i64 = eval(&s, "return pmacs.editor.cursor_line()"); let col: i64 = eval(&s, "return pmacs.editor.cursor_col()"); assert_eq!((line, col), (2, 1), "0-based landing from 1-based 3:2"); // M-, returns to the compilation buffer (jump ring). alt(&mut s, ','); assert_eq!(active_buffer_name(&s), "*compilation*"); } #[test] fn acc16_n_p_walk_error_lines_without_wrap() { let dir = tempfile::tempdir().unwrap(); let mut s = editor(); compile_and_finish( &mut s, "printf 'a.c:1:1: error: one\\nplain line\\nb.c:2:2: error: two\\n'", dir.path(), ); let row_of = |s: &EditorState| -> i64 { eval(s, "return pmacs.editor.cursor_line()") }; press(&mut s, KeyCode::Char('n')); let first = row_of(&s); press(&mut s, KeyCode::Char('n')); let second = row_of(&s); assert!(second > first, "n walks forward between error lines"); press(&mut s, KeyCode::Char('n')); assert_eq!(status(&s), "no more errors", "no wrap at the end"); assert_eq!(row_of(&s), second, "cursor stays"); press(&mut s, KeyCode::Char('p')); assert_eq!(row_of(&s), first, "p walks back"); press(&mut s, KeyCode::Char('p')); assert_eq!(status(&s), "no more errors", "no wrap at the start"); } #[test] fn acc17_chords_walk_compile_errors_across_files() { let dir = tempfile::tempdir().unwrap(); std::fs::write(dir.path().join("one.c"), "a\nb\n").unwrap(); std::fs::write(dir.path().join("two.c"), "c\nd\ne\n").unwrap(); let mut s = editor(); compile_and_finish( &mut s, "printf 'one.c:1:1: error: e1\\ntwo.c:3:1: error: e2\\n'", dir.path(), ); // M-g n visits the first error, then the second, then reports. alt(&mut s, 'g'); press(&mut s, KeyCode::Char('n')); assert!(active_buffer_name(&s).ends_with("one.c"), "first error"); alt(&mut s, 'g'); press(&mut s, KeyCode::Char('n')); assert!(active_buffer_name(&s).ends_with("two.c"), "second error"); alt(&mut s, 'g'); press(&mut s, KeyCode::Char('n')); assert_eq!(status(&s), "no more errors", "no wrap past the last"); assert!(active_buffer_name(&s).ends_with("two.c"), "stays put"); // M-g p walks back. alt(&mut s, 'g'); press(&mut s, KeyCode::Char('p')); assert!(active_buffer_name(&s).ends_with("one.c"), "previous error"); // C-x ` is the classic chord for the same dispatcher. ctrl(&mut s, 'x'); press(&mut s, KeyCode::Char('`')); assert!( active_buffer_name(&s).ends_with("two.c"), "C-x ` = error.next" ); } #[test] fn acc18_dispatcher_falls_back_to_diagnostics_without_a_claim() { let mut s = editor(); alt(&mut s, 'g'); press(&mut s, KeyCode::Char('n')); assert_eq!( status(&s), "diag: no LSP server for active buffer", "with no compile/grep claim, M-g n must reach diag.next \ (today's behavior preserved exactly)" ); } // --------------------------------------------------------------------------- // 19–22: recompile, q-target, kill, supersede // --------------------------------------------------------------------------- #[test] fn acc19_g_recompiles_and_clears_overlay_spans() { let dir = tempfile::tempdir().unwrap(); let counter = dir.path().join("count"); let mut s = editor(); // First run emits a severity-colored diagnostic (overlay span); // each run appends to the counter file. compile_and_finish( &mut s, &format!( "echo run >> {}; printf 'a.c:1:1: error: colored\\n'", counter.display() ), dir.path(), ); assert!(any_styled_cell(&render_active_window_to_grid( &mut s, 8, 60 ))); assert_eq!( std::fs::read_to_string(&counter).unwrap().lines().count(), 1 ); // g re-runs the stored command. press(&mut s, KeyCode::Char('g')); assert!( pump_until(&mut s, 10_000, |_| { std::fs::read_to_string(&counter).is_ok_and(|c| c.lines().count() == 2) }), "recompile must actually re-execute the command" ); // The rerun executes the SAME stored command, so its output // carries the same diagnostic; per-run reset is pinned by // checking the fresh run reached its own marker with // exactly one diagnostic parsed (not accumulated). assert!(pump_until(&mut s, 10_000, |s| compilation_text(s) .contains("[compile exited"))); assert_eq!( compile_errors(&s).len(), 1, "error list resets per run (not accumulated)" ); } #[test] fn acc20_compile_g_q_restores_the_original_buffer() { let dir = tempfile::tempdir().unwrap(); std::fs::write(dir.path().join("origin.txt"), "home\n").unwrap(); let mut s = editor(); exec( &s, &format!( "pmacs.buffer.find_or_open({:?})", dir.path().join("origin.txt").display().to_string() ), ); compile_and_finish(&mut s, "echo one", dir.path()); press(&mut s, KeyCode::Char('g')); assert!(pump_until(&mut s, 10_000, |s| compilation_text(s) .contains("[compile exited"))); press(&mut s, KeyCode::Char('q')); assert!( active_buffer_name(&s).ends_with("origin.txt"), "q restores the pre-compile buffer even after g (q-target \ not re-captured); active: {}", active_buffer_name(&s) ); } #[test] fn acc21_kill_produces_signaled_marker() { let dir = tempfile::tempdir().unwrap(); let mut s = editor(); compile_run(&s, "echo running; sleep 30", dir.path()); assert!(pump_until(&mut s, 5_000, |s| compilation_text(s) .contains("\nrunning\n"))); ctrl(&mut s, 'c'); ctrl(&mut s, 'k'); assert!( pump_until(&mut s, 5_000, |s| compilation_text(s) .contains("[compile killed by SIGTERM]")), "plain kill: SIGTERM marker; buffer:\n{}", compilation_text(&s) ); } #[test] fn acc22_supersede_resets_and_returns_to_baseline() { let dir = tempfile::tempdir().unwrap(); let mut s = editor(); assert_eq!(process_count(&s), 0); compile_run(&s, "echo first-run-output; sleep 30", dir.path()); assert!(pump_until(&mut s, 5_000, |s| compilation_text(s) .contains("\nfirst-run-output\n"))); compile_run(&s, "echo second-run-output", dir.path()); assert!(pump_until(&mut s, 10_000, |s| compilation_text(s) .contains("[compile exited with code 0]"))); let text = compilation_text(&s); assert!( !text.contains("\nfirst-run-output\n"), "old-run output must not land after the reset:\n{text}" ); assert!( !text.contains("killed by"), "the superseded run's exit marker must not land either:\n{text}" ); assert!( text.contains("\nsecond-run-output\n"), "new run streams:\n{text}" ); assert!( pump_until(&mut s, 5_000, |s| process_count(s) == 0), "both generations forgotten once drained" ); } // --------------------------------------------------------------------------- // 23–25: undo surfaces and the revision guard // --------------------------------------------------------------------------- #[test] fn acc23_all_seven_undo_redo_chords_are_status_noops() { let dir = tempfile::tempdir().unwrap(); let mut s = editor(); compile_and_finish(&mut s, "echo out", dir.path()); let before = compilation_text(&s); let check = |s: &mut EditorState, label: &str| { assert_eq!( status(s), "generated buffer: undo disabled", "{label} must reach the buffer-local no-op" ); assert_eq!(compilation_text(s), before, "{label} must not edit"); exec(s, "pmacs.editor.set_status('')"); }; ctrl(&mut s, '/'); check(&mut s, "C-/"); ctrl(&mut s, '_'); check(&mut s, "C-_"); ctrl(&mut s, '4'); check(&mut s, "C-4 (raw-terminal single-key undo)"); ctrl(&mut s, 'x'); press(&mut s, KeyCode::Char('u')); check(&mut s, "C-x u"); ctrl(&mut s, '?'); check(&mut s, "C-? (redo)"); ctrl_shift(&mut s, '_'); check(&mut s, "C-S-_ (redo)"); ctrl(&mut s, 'x'); press(&mut s, KeyCode::Char('r')); check(&mut s, "C-x r (redo)"); } #[test] fn acc24_command_path_undo_after_completed_run_recovers_immediately() { let dir = tempfile::tempdir().unwrap(); let mut s = editor(); exec( &s, &format!( "pmacs.shell.command('echo shell-out', {{ cwd = {:?} }})", dir.path().display().to_string() ), ); assert!(pump_until(&mut s, 10_000, |s| named_text( s, "*shell-command*" ) .contains("[shell exited with code 0]"))); // M-x buffer.undo: the command path rebinding cannot reach. No // pump event will ever arrive — recovery must come from the // buffer.after-edit subscription, synchronously. alt(&mut s, 'x'); type_str(&mut s, "buffer.undo"); press(&mut s, KeyCode::Enter); let text = named_text(&s, "*shell-command*"); assert!( text.contains(DESYNC), "desync marker must appear immediately (bite: fails when \ recovery only runs at pump/anchor time); buffer:\n{text}" ); assert!( errors_buffer(&s).is_empty(), "clean *errors*: {}", errors_buffer(&s) ); } #[test] fn acc25a_no_hook_shrink_mid_stream_recovers_and_reanchors() { let dir = tempfile::tempdir().unwrap(); std::fs::write(dir.path().join("b.c"), "x\ny\nz\n").unwrap(); let script = write_script( dir.path(), "slow.sh", "printf 'a.c:1:1: error: one\\n'\nsleep 1\nprintf 'b.c:2:1: error: two\\n'\n", ); let mut s = editor(); compile_run(&s, &format!("sh {script}"), dir.path()); assert!(pump_until(&mut s, 5_000, |s| compilation_text(s).contains(": one"))); let first_row: i64 = { // The first diagnostic's row, while its anchor is live. press(&mut s, KeyCode::Char('n')); eval(&s, "return pmacs.editor.cursor_line()") }; // Harness-eval mutation: outside dispatch, outside // with_after_edit_check — the actual no-hook producer the pump // guard owns. exec( &s, r#" for _, id in ipairs(pmacs.buffer.list()) do if pmacs.describe.buffer(id).name == "*compilation*" then id:delete(id:len() - 3, id:len(), { bypass_intercept = true }) end end "#, ); assert!( pump_until(&mut s, 10_000, |s| compilation_text(s) .contains("[compile exited")), "the pump must survive and finish; buffer:\n{}", compilation_text(&s) ); let text = compilation_text(&s); assert!( text.contains(&format!("\n{DESYNC}\n")), "newline-delimited marker; buffer:\n{text}" ); assert!( text.find(DESYNC).unwrap() < text.find("two").unwrap(), "streaming continued after the marker:\n{text}" ); assert!( errors_buffer(&s).is_empty(), "no spam: {}", errors_buffer(&s) ); // Pre-marker anchor dropped: RET on the old row reports. exec(&s, "pmacs.editor.goto_byte(0)"); let target = first_row; exec( &s, &format!("for _ = 1, {target} do pmacs.editor.move_down() end"), ); press(&mut s, KeyCode::Enter); assert_eq!(status(&s), "no error on this line", "stale anchor dropped"); // Fresh epoch: the post-marker diagnostic gets a working anchor. exec(&s, "pmacs.editor.goto_byte(0)"); press(&mut s, KeyCode::Char('n')); press(&mut s, KeyCode::Enter); assert!( active_buffer_name(&s).ends_with("b.c"), "post-marker diagnostic navigates; active: {}", active_buffer_name(&s) ); } #[test] fn acc25b_same_length_newline_moving_replace_is_caught() { // The length-guard killer: content changes, length doesn't, and // a newline moves so every anchor stays in bounds but rows lie. let dir = tempfile::tempdir().unwrap(); let script = write_script( dir.path(), "slow.sh", "printf 'a.c:1:1: error: one\\n'\nsleep 1\nprintf 'done\\n'\n", ); let mut s = editor(); compile_run(&s, &format!("sh {script}"), dir.path()); assert!(pump_until(&mut s, 5_000, |s| compilation_text(s).contains(": one"))); // Replace "one\n" with "one!" — same length, newline moved. exec( &s, r#" for _, id in ipairs(pmacs.buffer.list()) do if pmacs.describe.buffer(id).name == "*compilation*" then local text = id:slice(0, id:len()) local at = text:find("one\n", 1, true) id:replace(at - 1, at + 3, "one!", { bypass_intercept = true }) end end "#, ); assert!( pump_until(&mut s, 10_000, |s| compilation_text(s) .contains("[compile exited")), "pump survives; buffer:\n{}", compilation_text(&s) ); assert!( compilation_text(&s).contains(DESYNC), "revision guard catches what a length guard provably misses:\n{}", compilation_text(&s) ); } // --------------------------------------------------------------------------- // 26: ANSI + rendered styling + M-, retention // --------------------------------------------------------------------------- #[test] fn acc26_ansi_renders_styled_cells_and_survives_jump_back() { let dir = tempfile::tempdir().unwrap(); std::fs::write(dir.path().join("x.c"), "a\nb\n").unwrap(); let script = write_script( dir.path(), "color.sh", concat!( "printf '\\033[31mredtext\\033[0m plain\\n'\n", "printf 'progress 1\\rprogress 2\\n'\n", "printf 'x.c:1:1: error: e\\n'\n", ), ); let mut s = editor(); compile_and_finish(&mut s, &format!("sh {script}"), dir.path()); let text = compilation_text(&s); assert!(!text.contains('\u{1b}'), "no escape bytes:\n{text}"); assert!(text.contains("redtext"), "SGR text survives:\n{text}"); assert!( text.contains("progress 2") && !text.contains("progress 1"), "CR progress collapsed in place:\n{text}" ); // Attachment proven: a rendered cell in the ACTIVE WINDOW // carries style (handle spans alone would pass even when // attach_style_overlay was never called). assert!( any_styled_cell(&render_active_window_to_grid(&mut s, 10, 60)), "rendered TUI cell must carry the SGR span's style" ); // RET to the file and M-, back must retain styling (rides the // jump_back after-switch parity + re-attach subscription). press(&mut s, KeyCode::Char('n')); press(&mut s, KeyCode::Enter); assert!(active_buffer_name(&s).ends_with("x.c")); alt(&mut s, ','); assert_eq!(active_buffer_name(&s), "*compilation*"); assert!( any_styled_cell(&render_active_window_to_grid(&mut s, 10, 60)), "styling must survive the RET → M-, round trip" ); } // --------------------------------------------------------------------------- // 27: killed buffer mid-run // --------------------------------------------------------------------------- #[test] fn acc27_killed_buffer_terminates_run_and_recreates() { let dir = tempfile::tempdir().unwrap(); let mut s = editor(); compile_run(&s, "echo alive; sleep 30", dir.path()); assert!(pump_until(&mut s, 5_000, |s| compilation_text(s).contains("\nalive\n"))); exec( &s, r#" for _, id in ipairs(pmacs.buffer.list()) do if pmacs.describe.buffer(id).name == "*compilation*" then pmacs.buffer.remove(id) end end "#, ); assert!( pump_until(&mut s, 5_000, |s| process_count(s) == 0), "run terminated and forgotten after buffer death" ); assert!( errors_buffer(&s).is_empty(), "no spam: {}", errors_buffer(&s) ); // The next run recreates the buffer and completes. compile_and_finish(&mut s, "echo reborn", dir.path()); assert!(compilation_text(&s).contains("reborn")); } // --------------------------------------------------------------------------- // 28–30: grep-mode // --------------------------------------------------------------------------- fn grep_fixture(dir: &Path) { std::fs::create_dir_all(dir.join(".git")).unwrap(); std::fs::write( dir.join("f.txt"), "top\nzqxvbn_needle_77 here\nzqxvbn_needle_77 again\n", ) .unwrap(); } fn search(s: &EditorState, query: &str, root: &Path) { exec( s, &format!( "pmacs.project.search({query:?}, {{ root = {:?} }})", root.display().to_string() ), ); } fn search_done(s: &EditorState) -> bool { named_text(s, "*search-results*").contains("-- search ") } #[test] fn acc28_grep_panel_is_a_locations_buffer() { let dir = tempfile::tempdir().unwrap(); grep_fixture(dir.path()); let mut s = editor(); search(&s, "zqxvbn_needle_77", dir.path()); assert!(pump_until(&mut s, 10_000, search_done), "search completes"); assert_eq!(active_buffer_name(&s), "*search-results*"); let text = named_text(&s, "*search-results*"); assert!( text.contains("f.txt:2:0:"), "structured match line:\n{text}" ); // Read-only under dispatch. type_str(&mut s, "x"); assert_eq!(named_text(&s, "*search-results*"), text, "read-only"); // RET visits the first match (line 2 → 0-based 1; col 0). press(&mut s, KeyCode::Char('n')); press(&mut s, KeyCode::Enter); assert!(active_buffer_name(&s).ends_with("f.txt")); let line: i64 = eval(&s, "return pmacs.editor.cursor_line()"); assert_eq!(line, 1, "grep 1-based line normalized"); // The search claimed the error source: M-g n continues the walk // (RET re-seated the index at match 1, so next = match 2). alt(&mut s, ','); alt(&mut s, 'g'); press(&mut s, KeyCode::Char('n')); assert!( active_buffer_name(&s).ends_with("f.txt"), "M-g n walks grep matches (source claimed)" ); let line: i64 = eval(&s, "return pmacs.editor.cursor_line()"); assert_eq!(line, 2, "M-g n advanced to the second match"); // A second search supersedes: fresh page. alt(&mut s, ','); search(&s, "top", dir.path()); assert!(pump_until(&mut s, 10_000, search_done)); let text2 = named_text(&s, "*search-results*"); assert!( text2.contains("Searching for: top") && !text2.contains("zqxvbn_needle_77 here"), "supersede gives a fresh page:\n{text2}" ); } #[test] fn acc29_grep_kill_mid_search_is_safe_and_masking_is_prevented() { // A wide fixture keeps the stream alive across many ticks so a // mid-stream window deterministically exists. let dir = tempfile::tempdir().unwrap(); std::fs::create_dir_all(dir.path().join(".git")).unwrap(); for i in 0..2000 { std::fs::write( dir.path().join(format!("f{i:04}.txt")), "zqxvbn_needle_77\n", ) .unwrap(); } // (a) masking prevention: a no-hook edit between producer writes // is detected by the NEXT producer write (a later batch or the // close trailer), not silently absorbed. let mut s = editor(); search(&s, "zqxvbn_needle_77", dir.path()); assert!( pump_until(&mut s, 10_000, |s| { let t = named_text(s, "*search-results*"); t.contains(":1:0:") && !t.contains("-- search ") }), "must observe a mid-stream state (matches landed, not closed)" ); exec( &s, r#" for _, id in ipairs(pmacs.buffer.list()) do if pmacs.describe.buffer(id).name == "*search-results*" then id:insert(id:len(), "INTRUDER", { bypass_intercept = true }) end end "#, ); assert!(pump_until(&mut s, 15_000, search_done)); let text = named_text(&s, "*search-results*"); let marker_at = text.find(DESYNC); let trailer_at = text.find("-- search ").unwrap(); assert!( marker_at.is_some() && marker_at.unwrap() < trailer_at, "the next producer write must mark the mismatch before \ appending (not mask it); tail:\n…{}", &text[text.len().saturating_sub(400)..] ); assert!( errors_buffer(&s).is_empty(), "no spam: {}", errors_buffer(&s) ); // (b) killing the panel mid-search: no stale-handle writes, and // the next search recreates the buffer. let mut s = editor(); search(&s, "zqxvbn_needle_77", dir.path()); assert!(pump_until(&mut s, 10_000, |s| named_text( s, "*search-results*" ) .contains(":1:0:"))); exec( &s, r#" for _, id in ipairs(pmacs.buffer.list()) do if pmacs.describe.buffer(id).name == "*search-results*" then pmacs.buffer.remove(id) end end "#, ); // Drain whatever the worker still delivers. let _ = pump_until(&mut s, 1_000, |_| false); assert!( errors_buffer(&s).is_empty(), "no spam: {}", errors_buffer(&s) ); search(&s, "zqxvbn_needle_77", dir.path()); assert!( pump_until(&mut s, 15_000, |s| named_text(s, "*search-results*") .contains(":1:0:")), "a subsequent search recreates the panel and works" ); } #[test] fn acc30_grep_root_retained_across_interactive_supersede() { let dir = tempfile::tempdir().unwrap(); grep_fixture(dir.path()); let mut s = editor(); // First search from a project file: root comes from // pmacs.project.detect (the .git marker). exec( &s, &format!( "pmacs.buffer.find_or_open({:?})", dir.path().join("f.txt").display().to_string() ), ); exec(&s, "pmacs.project.search('zqxvbn_needle_77')"); assert!(pump_until(&mut s, 10_000, |s| named_text( s, "*search-results*" ) .contains("f.txt:2:0:"))); // Second search issued from inside the pathless panel, no // opts.root: the panel's stored root must be reused (the "." // fallback would search the test process's cwd and find // nothing). exec(&s, "pmacs.project.search('zqxvbn_needle_77')"); assert!( pump_until(&mut s, 10_000, |s| { let t = named_text(s, "*search-results*"); t.contains("f.txt:2:0:") && t.contains("-- search ") }), "second interactive search must run with the first search's \ root; panel:\n{}", named_text(&s, "*search-results*") ); // And RET still resolves against that root. press(&mut s, KeyCode::Char('n')); press(&mut s, KeyCode::Enter); assert!(active_buffer_name(&s).ends_with("f.txt")); } // --------------------------------------------------------------------------- // 31–33: shell-command, q, round-trip flag // --------------------------------------------------------------------------- #[test] fn acc31_shell_command_via_m_bang_does_not_claim_errors() { let dir = tempfile::tempdir().unwrap(); std::fs::write(dir.path().join("one.c"), "a\n").unwrap(); let mut s = editor(); // Compile first so the error source is claimed by compile. compile_and_finish(&mut s, "printf 'one.c:1:1: error: e\\n'", dir.path()); // M-! prompts; type the command; RET runs it. alt(&mut s, '!'); type_str(&mut s, "echo shellout"); press(&mut s, KeyCode::Enter); assert!( pump_until(&mut s, 10_000, |s| named_text(s, "*shell-command*") .contains("[shell exited with code 0]")), "M-! output + exit marker; buffer:\n{}", named_text(&s, "*shell-command*") ); assert!(named_text(&s, "*shell-command*").contains("\nshellout\n")); // The shell run must NOT have stolen the claim: M-g n still // walks the compile errors. alt(&mut s, 'g'); press(&mut s, KeyCode::Char('n')); assert!( active_buffer_name(&s).ends_with("one.c"), "M-g n after M-! still walks the prior compile; active: {}", active_buffer_name(&s) ); } #[test] fn acc32_q_restores_previous_buffer_from_all_three() { let dir = tempfile::tempdir().unwrap(); grep_fixture(dir.path()); std::fs::write(dir.path().join("home.txt"), "hi\n").unwrap(); let mut s = editor(); let open_home = format!( "pmacs.buffer.find_or_open({:?})", dir.path().join("home.txt").display().to_string() ); exec(&s, &open_home); // *compilation* compile_and_finish(&mut s, "echo x", dir.path()); press(&mut s, KeyCode::Char('q')); assert!( active_buffer_name(&s).ends_with("home.txt"), "q from compile" ); // *shell-command* exec( &s, &format!( "pmacs.shell.command('echo y', {{ cwd = {:?} }})", dir.path().display().to_string() ), ); assert!(pump_until(&mut s, 10_000, |s| named_text( s, "*shell-command*" ) .contains("[shell exited"))); press(&mut s, KeyCode::Char('q')); assert!(active_buffer_name(&s).ends_with("home.txt"), "q from shell"); // *search-results* search(&s, "zqxvbn_needle_77", dir.path()); assert!(pump_until(&mut s, 10_000, search_done)); press(&mut s, KeyCode::Char('q')); assert!( active_buffer_name(&s).ends_with("home.txt"), "q from search" ); } #[test] fn acc33_round_trip_input_is_set_on_generated_buffers() { let dir = tempfile::tempdir().unwrap(); grep_fixture(dir.path()); let mut s = editor(); compile_and_finish(&mut s, "echo x", dir.path()); assert!( s.core.borrow().active_buffer_round_trips(), "*compilation* must round-trip (semantic frontends gate their \ optimistic path on this)" ); exec( &s, &format!( "pmacs.shell.command('echo y', {{ cwd = {:?} }})", dir.path().display().to_string() ), ); assert!( s.core.borrow().active_buffer_round_trips(), "*shell-command*" ); search(&s, "zqxvbn_needle_77", dir.path()); assert!( s.core.borrow().active_buffer_round_trips(), "*search-results*" ); } // --------------------------------------------------------------------------- // PR #113 round 1 — bite tests (one per finding; each observed // failing against the pre-fix tree via scripts/bite) // --------------------------------------------------------------------------- #[test] fn r1f1_non_finite_coordinates_fail_closed() { // A 400-digit line capture tonumbers to math.huge; pre-fix it // was stored and any visit walked forever. let dir = tempfile::tempdir().unwrap(); let mut s = editor(); let digits = "9".repeat(400); compile_and_finish( &mut s, &format!("printf 'h.c:{digits}:1: error: e\\n'"), dir.path(), ); assert!( compile_errors(&s).is_empty(), "a non-finite line coordinate must be discarded; got {:?}", compile_errors(&s) ); } #[test] fn r1f1_beyond_eol_column_clamps_to_the_target_row() { // Column past EOL: pre-fix the walk marched move_right across // newlines and landed rows away from the diagnostic's line (and // an astronomical value walked effectively forever). let dir = tempfile::tempdir().unwrap(); std::fs::write(dir.path().join("w.c"), "ab\ncd\nef\n").unwrap(); let mut s = editor(); compile_and_finish(&mut s, "printf 'w.c:1:500: error: e\\n'", dir.path()); press(&mut s, KeyCode::Char('n')); press(&mut s, KeyCode::Enter); assert!(active_buffer_name(&s).ends_with("w.c")); let line: i64 = eval(&s, "return pmacs.editor.cursor_line()"); assert_eq!( line, 0, "the column walk must clamp at the target row's EOL, not run \ onto later rows" ); } #[test] fn r1f2_grep_command_path_undo_after_completed_search_recovers() { let dir = tempfile::tempdir().unwrap(); grep_fixture(dir.path()); let mut s = editor(); search(&s, "zqxvbn_needle_77", dir.path()); assert!(pump_until(&mut s, 10_000, search_done), "search completes"); // M-x buffer.undo in the completed panel: no producer write or // navigation may ever come — recovery must be immediate via the // buffer.after-edit subscription. alt(&mut s, 'x'); type_str(&mut s, "buffer.undo"); press(&mut s, KeyCode::Enter); let text = named_text(&s, "*search-results*"); assert!( text.contains(DESYNC), "desync marker must appear immediately in the grep panel; \ buffer:\n{text}" ); assert!( errors_buffer(&s).is_empty(), "clean *errors*: {}", errors_buffer(&s) ); } #[test] fn r1f3_rustc_arrow_paths_with_spaces_parse() { let dir = tempfile::tempdir().unwrap(); let script = write_script( dir.path(), "space.sh", "printf ' --> /tmp/my dir/foo.rs:12:4\\n'\n", ); let mut s = editor(); compile_and_finish(&mut s, &format!("sh {script}"), dir.path()); assert_eq!( compile_errors(&s), vec![("/tmp/my dir/foo.rs".to_owned(), 11, 3, None)], "the rustc rule must capture space-containing paths whole \ (pre-fix the two-part fallback recorded arrow junk at col 0)" ); } #[test] fn r1f4_capture_indexes_above_three_and_absent_columns() { let dir = tempfile::tempdir().unwrap(); // (a) a valid four-capture rule with col = 4 must store the // fourth capture, not silently column 0. let mut s = editor(); exec( &s, r#" pmacs.compile.rules = { { pattern = "(q%.c):(%d+):(x):(%d+)", file = 1, line = 2, col = 4 }, } "#, ); compile_and_finish(&mut s, "printf 'q.c:7:x:9 error\\n'", dir.path()); assert_eq!( compile_errors(&s), vec![("q.c".to_owned(), 6, 8, Some("error".to_owned()))], "capture index 4 must be honored" ); // (b) fractional indexes are malformed; (c) a rule that names a // column its match didn't produce rejects the match. let mut s = editor(); exec( &s, r#" pmacs.compile.rules = { { pattern = "(a%.c):(%d+):", file = 1, line = 2, col = 1.5 }, { pattern = "(r%.c):(%d+):?(%d*)", file = 1, line = 2, col = 3 }, } "#, ); compile_run(&s, "printf 'a.c:3: e\\nr.c:5: e\\n'", dir.path()); assert!( status(&s).contains("skipped 1 malformed"), "the fractional index is rejected at validation; got: {}", status(&s) ); assert!(pump_until(&mut s, 10_000, |s| compilation_text(s) .contains("[compile exited"))); assert!( compile_errors(&s).is_empty(), "an empty column capture under a col-naming rule must reject \ the match, not store column 0; got {:?}", compile_errors(&s) ); } #[test] fn r1f5_user_global_cannot_shadow_the_marker_helper() { let dir = tempfile::tempdir().unwrap(); let mut s = editor(); // A hostile (or merely colliding) user global with the helper's // old name: pre-fix this replaced the module's function and its // error consumed the terminal event before forget ran. exec(&s, "_G.emit_text_raw = function() error('shadowed') end"); compile_and_finish(&mut s, "echo fine", dir.path()); assert!( compilation_text(&s).contains("[compile exited with code 0]"), "the exit marker must come from the module's local helper" ); assert!( errors_buffer(&s).is_empty(), "no error spam: {}", errors_buffer(&s) ); assert!( pump_until(&mut s, 3_000, |s| process_count(s) == 0), "terminal-event cleanup (forget) must have run" ); } #[test] fn r1f6_wrong_spec_types_error_instead_of_defaulting() { let s = editor(); let (ok, err): (bool, String) = eval( &s, r#" local ok, err = pcall(pmacs.process.spawn, { label = "t", command = "/bin/true", stdin = true }) return ok, tostring(err) "#, ); assert!(!ok, "stdin = true (boolean) must be a hard error"); assert!(err.contains("stdin must be"), "pointed message; got: {err}"); let (ok, err): (bool, String) = eval( &s, r#" local ok, err = pcall(pmacs.process.spawn, { label = "t", command = "/bin/true", group = "true" }) return ok, tostring(err) "#, ); assert!(!ok, "group = \"true\" (string) must be a hard error"); assert!( err.contains("group must be a boolean"), "pointed message; got: {err}" ); } #[test] fn r1f7_resync_invalidates_the_public_byte_anchor() { let dir = tempfile::tempdir().unwrap(); let mut s = editor(); compile_and_finish(&mut s, "printf 'a.c:1:1: error: e\\n'", dir.path()); let has_anchor: bool = eval( &s, "return pmacs.compile.errors()[1].line_start_byte ~= nil", ); assert!(has_anchor, "pre-desync entries carry the byte anchor"); // Trigger the guard through the command path. alt(&mut s, 'x'); type_str(&mut s, "buffer.undo"); press(&mut s, KeyCode::Enter); assert!(compilation_text(&s).contains(DESYNC), "marker appended"); let has_anchor: bool = eval( &s, "return pmacs.compile.errors()[1].line_start_byte ~= nil", ); assert!( !has_anchor, "total pre-marker anchor invalidation includes the public \ line_start_byte, not just the display row" ); } #[test] fn r1f8_inherited_cwd_resolves_to_the_daemon_working_directory() { // Pathless scratch buffer, no opts.cwd, no project: the header // must print the real working directory, and relative error // paths must resolve against it explicitly. let mut s = editor(); exec(&s, "pmacs.compile.run('echo hi')"); assert!(pump_until(&mut s, 10_000, |s| compilation_text(s) .contains("[compile exited"))); let cwd = std::env::current_dir().unwrap().display().to_string(); let text = compilation_text(&s); assert!( text.contains(&format!("Directory: {cwd}")), "the header must name the daemon's actual working directory; \ got:\n{text}" ); assert!( !text.contains("(inherited)") && !text.contains("(unknown)"), "no placeholder when the identity API is available:\n{text}" ); } #[test] fn r1f9_truncated_utf8_at_eof_becomes_the_replacement_character() { let dir = tempfile::tempdir().unwrap(); // \303 (0xC3) opens a two-byte sequence that never completes: // the parser's cross-feed buffer holds it, and only the new // stream-end finish() can flush it as U+FFFD. let script = write_script(dir.path(), "trunc.sh", "printf 'abc\\303'\n"); let mut s = editor(); compile_and_finish(&mut s, &format!("sh {script}"), dir.path()); let text = compilation_text(&s); assert!( text.contains("abc\u{FFFD}"), "the truncated sequence must surface as U+FFFD, not vanish; \ buffer:\n{text}" ); } #[test] fn r1f10_builtin_default_rules_survive_in_place_mutation() { let dir = tempfile::tempdir().unwrap(); let mut s = editor(); // Corrupt an entry IN PLACE, then degrade the container: the // "built-in defaults" fallback must be a true copy, not an alias // of the mutated table. exec( &s, "pmacs.compile.rules[2] = 'junk'; pmacs.compile.rules = 42", ); compile_and_finish(&mut s, "printf 'foo.c:7:2: warning: w\\n'", dir.path()); assert_eq!( compile_errors(&s), vec![("foo.c".to_owned(), 6, 1, Some("warning".to_owned()))], "the gcc three-part rule from the TRUE defaults must match \ (the aliased pre-fix table lost it and the two-part fallback \ stored column 0)" ); }