pmacs/tests/m8_6_acceptance.rs

663 lines
23 KiB
Rust

// tests/m8_6_acceptance.rs --- T M8.6 magit-class git status integration.
//! Acceptance for the M8.6 deliverable: wiring the M8.5 foldable
//! section view to actual `git status` output. Spec acceptance
//! bullets:
//!
//! 1. All five canonical sections render correctly on a typical
//! repository (working tree, staged, recent commits, branches,
//! stashes).
//! 2. Refresh after an external commit reflects the new state
//! within 500 ms.
//! 3. Empty sections (no stashes, etc.) render as a one-line
//! placeholder rather than disappearing.
//!
//! Plus regressions:
//!
//! * Section IDs are stable across refresh; fold-state is preserved.
//! * Cursor's section is preserved across refresh.
//! * Manual refresh-status command works.
//! * The four parser helpers (porcelain v2, log, branches, stashes)
//! round-trip canonical inputs without invoking git.
//! * Non-repo path produces a refresh failure but doesn't crash the
//! package.
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::{Duration, Instant};
use pmacs::editor::EditorState;
use pmacs::lua_bindings::PackageInstallOverride;
use tempfile::TempDir;
fn magit_package_path() -> PathBuf {
let here = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
here.join("tests").join("fixtures").join("pmacs-magit")
}
fn pump_until<F: Fn(&EditorState) -> bool>(state: &mut EditorState, predicate: F) {
pump_until_with_deadline(state, predicate, Duration::from_secs(5));
}
fn pump_until_with_deadline<F: Fn(&EditorState) -> bool>(
state: &mut EditorState,
predicate: F,
timeout: Duration,
) {
let deadline = Instant::now() + timeout;
while !predicate(state) {
assert!(
Instant::now() < deadline,
"async pump deadline exceeded after {timeout:?}"
);
// M8.6 runs `git` via pmacs.process.spawn; the supervisor's
// events (stdout/stderr/exited) only show up if we tick
// both the async runtime AND the process supervisor each
// iteration --- the editor's main loop does both per pass
// (editor.rs:897-898), so tests have to as well.
state.tick_async();
state.tick_processes();
std::thread::sleep(Duration::from_millis(2));
}
}
fn editor_with_magit() -> (EditorState, TempDir, TempDir) {
let cache = tempfile::tempdir().expect("cache tempdir");
let user_root = tempfile::tempdir().expect("user-root tempdir");
let mut state = EditorState::new_with_roots(&crate::iso::roots());
state.lua_host.reopen_init_phase_for_testing();
state.lua_host.set_package_install_override(
PackageInstallOverride::new()
.with_cache_dir(cache.path().to_path_buf())
.with_user_install_root(user_root.path().to_path_buf()),
);
let pkg = magit_package_path();
let pkg_str = pkg.display().to_string();
let install = format!(
r#"
pmacs.packages.install_local("{pkg_str}")
require("pmacs-magit")
"#
);
state
.lua_host
.eval(Some("magit-install"), &install)
.unwrap_or_else(|e| panic!("install_local + require failed: {e}"));
(state, cache, user_root)
}
/// Build a small git repo for tests: `git init`, set user, commit a
/// file. Returns the `TempDir` owning the repo.
fn make_test_repo() -> TempDir {
let dir = tempfile::tempdir().expect("tempdir");
git_run(dir.path(), &["init", "-b", "main"]);
git_run(dir.path(), &["config", "user.email", "test@example.com"]);
git_run(dir.path(), &["config", "user.name", "Test User"]);
std::fs::write(dir.path().join("README.md"), b"hello\n").expect("write README");
git_run(dir.path(), &["add", "README.md"]);
git_run(dir.path(), &["commit", "-m", "initial commit"]);
dir
}
fn git_run(cwd: &Path, args: &[&str]) {
let status = Command::new("git")
.args(args)
.current_dir(cwd)
.status()
.unwrap_or_else(|e| panic!("git {args:?} failed to spawn: {e}"));
assert!(status.success(), "git {args:?} returned {status}");
}
fn open_status(state: &mut EditorState, repo_root: &Path) {
let chunk = format!(
r#"
_G.M8_6_OPEN_DONE = nil
pmacs.async(function()
require("pmacs-magit").open_status({path:?})
_G.M8_6_OPEN_DONE = true
end)
"#,
path = repo_root.display().to_string()
);
state
.lua_host
.eval(Some("open_status"), &chunk)
.unwrap_or_else(|e| panic!("open_status eval failed: {e}"));
pump_until(state, |s| {
s.lua_host
.lua()
.globals()
.get::<bool>("M8_6_OPEN_DONE")
.unwrap_or(false)
});
}
fn active_buffer_text(state: &mut EditorState) -> String {
state
.lua_host
.lua()
.load(
r"
local buf = pmacs.window.buffer()
return buf:slice(0, buf:len())
",
)
.eval::<String>()
.expect("read active buffer")
}
// ---------------------------------------------------------------------------
// Bullet 1 --- five canonical sections render on a typical repo
// ---------------------------------------------------------------------------
#[test]
fn magit_status_renders_five_sections_on_typical_repo() {
let repo = make_test_repo();
// Stash setup goes FIRST: `git stash push` (no paths) stashes
// every change in the working tree and the index, so anything
// we set up before stashing gets wiped. Stash now while the
// tree is clean from make_test_repo().
std::fs::write(repo.path().join("for-stash.txt"), b"stashed content\n")
.expect("write stash file");
git_run(repo.path(), &["add", "for-stash.txt"]);
git_run(repo.path(), &["stash", "push", "-m", "test-stash"]);
// After stash push, the tree is clean again (the for-stash.txt
// change is in the stash entry). Now build the "typical" state
// we want to render.
std::fs::write(repo.path().join("README.md"), b"hello\nworld\n").expect("modify README");
git_run(repo.path(), &["add", "README.md"]); // staged
std::fs::write(repo.path().join("README.md"), b"hello\nworld\nmore\n").expect("modify again");
// README.md now has X=M (staged) and Y=M (unstaged).
std::fs::write(repo.path().join("untracked.txt"), b"new\n").expect("write untracked");
git_run(repo.path(), &["branch", "feature"]);
let (mut state, _c, _u) = editor_with_magit();
open_status(&mut state, repo.path());
let text = active_buffer_text(&mut state);
// All five canonical sections visible by their stable IDs (the
// visible header text is "<v|>| > Title (count)").
for section_title in [
"Working tree changes",
"Staged changes",
"Recent commits",
"Branches",
"Stashes",
] {
assert!(
text.contains(section_title),
"section {section_title:?} must render; got: {text}"
);
}
// The actual content surfaced.
assert!(
text.contains("README.md"),
"modified README must show: {text}"
);
assert!(
text.contains("untracked.txt"),
"untracked must show: {text}"
);
assert!(text.contains("feature"), "feature branch must show: {text}");
assert!(text.contains("test-stash"), "stash entry must show: {text}");
assert!(
text.contains("initial commit"),
"initial commit must show: {text}"
);
}
// ---------------------------------------------------------------------------
// Bullet 2 --- refresh after external commit reflects new state within 500 ms
// ---------------------------------------------------------------------------
#[test]
fn magit_status_refreshes_after_external_commit_within_500ms() {
let repo = make_test_repo();
let (mut state, _c, _u) = editor_with_magit();
open_status(&mut state, repo.path());
// Initial buffer shows "initial commit".
let initial = active_buffer_text(&mut state);
assert!(
initial.contains("initial commit"),
"initial state must include initial commit; got: {initial}"
);
assert!(
!initial.contains("second commit"),
"second commit hasn't happened yet"
);
// Externally make a new commit.
std::fs::write(repo.path().join("README.md"), b"v2\n").expect("modify");
git_run(repo.path(), &["add", "README.md"]);
git_run(repo.path(), &["commit", "-m", "second commit"]);
// Pump until the polling loop reflects the new commit. Acceptance
// budget is 500 ms; we use 750 ms to keep the test from being
// flaky on slow CI while still failing if polling is broken.
let started = Instant::now();
pump_until_with_deadline(
&mut state,
|s| {
s.lua_host
.lua()
.load(
r"
local buf = pmacs.window.buffer()
local text = buf:slice(0, buf:len())
return text:find('second commit', 1, true) ~= nil
",
)
.eval::<bool>()
.unwrap_or(false)
},
Duration::from_millis(750),
);
let elapsed = started.elapsed();
// Soft check on latency: the polling cadence is 250 ms, so on
// a healthy run we settle in well under 500 ms. Anything past
// 750 ms means the polling loop isn't running --- the deadline
// above already enforces that.
assert!(
elapsed < Duration::from_millis(750),
"refresh latency {elapsed:?} exceeded 750 ms budget"
);
}
// ---------------------------------------------------------------------------
// Bullet 3 --- empty sections render as one-line placeholder
// ---------------------------------------------------------------------------
#[test]
fn magit_status_empty_sections_render_as_placeholders() {
// A fresh repo with one initial commit: no working-tree changes,
// no staged changes, no extra branches, no stashes. Only the
// "Recent commits" section has real content.
let repo = make_test_repo();
let (mut state, _c, _u) = editor_with_magit();
open_status(&mut state, repo.path());
let text = active_buffer_text(&mut state);
// Every empty section shows its placeholder body.
assert!(
text.contains("(no working-tree changes)"),
"empty working tree must show placeholder: {text}"
);
assert!(
text.contains("(nothing staged)"),
"empty staged must show placeholder: {text}"
);
assert!(
text.contains("(no stashes)"),
"empty stashes must show placeholder: {text}"
);
// Recent commits has one entry (the initial commit), so its
// placeholder is NOT shown.
assert!(
!text.contains("(no commits yet)"),
"non-empty Recent commits must not show its placeholder: {text}"
);
// Branches has the implicit `main`; placeholder not shown.
assert!(
!text.contains("(no branches)"),
"non-empty Branches must not show its placeholder: {text}"
);
}
// ---------------------------------------------------------------------------
// Regression: section IDs stable across refresh, fold-state preserved
// ---------------------------------------------------------------------------
#[test]
fn magit_status_fold_state_preserved_across_refresh() {
let repo = make_test_repo();
let (mut state, _c, _u) = editor_with_magit();
open_status(&mut state, repo.path());
// Collapse "log" (Recent commits) section.
state
.lua_host
.eval(
Some("collapse-log"),
r#"
local te = require("pmacs-magit").__pmacs_magit_test_seam_DO_NOT_USE
local h = te.active_handle()
te.fold.toggle(h.fold_state, "log")
te.repaint_visible(h)
"#,
)
.expect("collapse log");
let collapsed = active_buffer_text(&mut state);
assert!(
!collapsed.contains("initial commit"),
"log body must be hidden after collapse: {collapsed}"
);
// Trigger a manual refresh (which produces a new spec and calls
// update_spec). Fold-state should survive because `log` is a
// stable ID across refreshes.
state
.lua_host
.eval(
Some("refresh"),
r#"pmacs.command.invoke("pmacs-magit.refresh-status")"#,
)
.expect("refresh");
pump_until(&mut state, |s| {
s.lua_host
.lua()
.load(
r"
local te = require('pmacs-magit').__pmacs_magit_test_seam_DO_NOT_USE
local h = te.active_handle()
return h ~= nil and h.refresh_pending == false
",
)
.eval::<bool>()
.unwrap_or(false)
});
let after = active_buffer_text(&mut state);
assert!(
!after.contains("initial commit"),
"log section must remain collapsed after refresh: {after}"
);
// Verify the fold_state still has "log" collapsed.
let log_state: String = state
.lua_host
.lua()
.load(
r#"
local te = require("pmacs-magit").__pmacs_magit_test_seam_DO_NOT_USE
local h = te.active_handle()
return tostring(h.fold_state.log)
"#,
)
.eval()
.expect("log fold state");
assert_eq!(log_state, "collapsed");
}
// ---------------------------------------------------------------------------
// Regression: cursor stays on the same section across refresh
// ---------------------------------------------------------------------------
#[test]
fn magit_status_cursor_section_preserved_across_refresh() {
let repo = make_test_repo();
let (mut state, _c, _u) = editor_with_magit();
open_status(&mut state, repo.path());
// Move cursor to Section "log" header (section_at would resolve
// it back to "log"). Sections in default order are working,
// staged, log, branches, stashes; each takes 2 lines (header +
// 1-line placeholder body) when empty. Empty repo: working (2
// lines, header + placeholder), staged (2 lines), so log header
// is at line 4. Move down 4 times.
state
.lua_host
.eval(
Some("seek-log"),
r"for _ = 1, 4 do pmacs.editor.move_down() end",
)
.expect("seek log");
let on_log: String = state
.lua_host
.lua()
.load(
r#"
local te = require("pmacs-magit").__pmacs_magit_test_seam_DO_NOT_USE
local h = te.active_handle()
local line = pmacs.editor.cursor_line()
return tostring(te.fold.section_at(h.projection, line))
"#,
)
.eval()
.expect("on log");
assert_eq!(on_log, "log", "setup: cursor must be on log section");
// Manual refresh.
state
.lua_host
.eval(
Some("refresh"),
r#"pmacs.command.invoke("pmacs-magit.refresh-status")"#,
)
.expect("refresh");
pump_until(&mut state, |s| {
s.lua_host
.lua()
.load(
r"
local te = require('pmacs-magit').__pmacs_magit_test_seam_DO_NOT_USE
local h = te.active_handle()
return h ~= nil and h.refresh_pending == false
",
)
.eval::<bool>()
.unwrap_or(false)
});
// Cursor should still be on the log section.
let after: String = state
.lua_host
.lua()
.load(
r#"
local te = require("pmacs-magit").__pmacs_magit_test_seam_DO_NOT_USE
local h = te.active_handle()
local line = pmacs.editor.cursor_line()
return tostring(te.fold.section_at(h.projection, line))
"#,
)
.eval()
.expect("after");
assert_eq!(after, "log", "cursor must still be on log after refresh");
}
// ---------------------------------------------------------------------------
// Regression: refresh-status command exists + works
// ---------------------------------------------------------------------------
#[test]
fn magit_refresh_status_command_picks_up_new_commit() {
let repo = make_test_repo();
let (mut state, _c, _u) = editor_with_magit();
open_status(&mut state, repo.path());
// External change.
std::fs::write(repo.path().join("README.md"), b"v2\n").expect("modify");
git_run(repo.path(), &["add", "README.md"]);
git_run(repo.path(), &["commit", "-m", "manual-refresh-target"]);
// Manual refresh (rather than waiting for the polling loop).
state
.lua_host
.eval(
Some("refresh-now"),
r#"pmacs.command.invoke("pmacs-magit.refresh-status")"#,
)
.expect("refresh-now");
pump_until(&mut state, |s| {
s.lua_host
.lua()
.load(
r"
local buf = pmacs.window.buffer()
local text = buf:slice(0, buf:len())
return text:find('manual-refresh-target', 1, true) ~= nil
",
)
.eval::<bool>()
.unwrap_or(false)
});
let text = active_buffer_text(&mut state);
assert!(text.contains("manual-refresh-target"));
}
// ---------------------------------------------------------------------------
// Regression: open_status validates input
// ---------------------------------------------------------------------------
#[test]
fn magit_open_status_rejects_non_string_repo_root() {
let (state, _c, _u) = editor_with_magit();
let msg: String = state
.lua_host
.lua()
.load(
r#"
local ok, err = pcall(function()
require("pmacs-magit").open_status(nil)
end)
assert(not ok, "nil repo_root must be rejected")
return tostring(err)
"#,
)
.eval()
.expect("nil-repo eval");
assert!(
msg.contains("non-empty string"),
"rejection must explain why: {msg}"
);
}
// ---------------------------------------------------------------------------
// Parser unit tests via the test seam
// ---------------------------------------------------------------------------
#[test]
fn magit_parse_porcelain_v2_basic_inputs() {
let (state, _c, _u) = editor_with_magit();
let lua = state.lua_host.lua();
// "1" lines: ordinary changed files. "2" lines: rename/copy.
// "?" lines: untracked. The Lua `"# branch.head main"` literal
// contains `"#`, which would close a single-hash Rust raw string;
// bumping to `r##"..."##` lets the literal pass through.
let json: String = lua
.load(
r##"
local te = require("pmacs-magit").__pmacs_magit_test_seam_DO_NOT_USE
local input = table.concat({
"# branch.head main",
"1 .M N... 100644 100644 100644 abc def README.md",
"1 M. N... 100644 100644 100644 def 123 staged.txt",
"? untracked.txt",
}, "\n") .. "\n"
local r = te.status.parse_porcelain_v2(input)
return string.format(
"branch=%s|staged=%d|unstaged=%d|untracked=%d|first_unstaged=%s",
r.branch, #r.staged, #r.unstaged, #r.untracked,
r.unstaged[1] or "(none)"
)
"##,
)
.eval()
.expect("porcelain parse");
// "1 .M ... README.md" -> Y is M (unstaged), X is . (not staged)
// "1 M. ... staged.txt" -> X is M (staged), Y is . (not unstaged)
// ? untracked.txt
assert_eq!(
json,
"branch=main|staged=1|unstaged=1|untracked=1|first_unstaged=M README.md"
);
}
#[test]
fn magit_parse_log_basic_input() {
let (state, _c, _u) = editor_with_magit();
let lua = state.lua_host.lua();
let result: String = lua
.load(
r#"
local te = require("pmacs-magit").__pmacs_magit_test_seam_DO_NOT_USE
local input = "abc1234 first commit\ndef5678 second commit\n"
local entries = te.status.parse_log(input)
return string.format("%d|%s|%s", #entries,
entries[1].hash, entries[2].subject)
"#,
)
.eval()
.expect("log parse");
assert_eq!(result, "2|abc1234|second commit");
}
#[test]
fn magit_parse_branches_marks_current() {
let (state, _c, _u) = editor_with_magit();
let lua = state.lua_host.lua();
let result: String = lua
.load(
r#"
local te = require("pmacs-magit").__pmacs_magit_test_seam_DO_NOT_USE
local input = " feature\n* main\n release\n"
local r = te.status.parse_branches(input)
return string.format("current=%s|count=%d|first=%s",
r.current, #r.all, r.all[1])
"#,
)
.eval()
.expect("branches parse");
// `git branch --list` outputs branches in encounter order
// (alphabetical with the current-branch marker inline), so
// `all[1]` is whichever branch sorts first --- "feature", not
// the current one. The current is named separately via
// `r.current`.
assert_eq!(result, "current=main|count=3|first=feature");
}
#[test]
fn magit_parse_stashes_basic_input() {
let (state, _c, _u) = editor_with_magit();
let lua = state.lua_host.lua();
let result: String = lua
.load(
r#"
local te = require("pmacs-magit").__pmacs_magit_test_seam_DO_NOT_USE
local input = "stash@{0}: WIP on main: abc123 work\nstash@{1}: On main: def quick fix\n"
local entries = te.status.parse_stashes(input)
return string.format("%d|%s|%s", #entries, entries[1].ref, entries[2].subject)
"#,
)
.eval()
.expect("stash parse");
assert_eq!(result, "2|stash@{0}|On main: def quick fix");
}
// ---------------------------------------------------------------------------
// Regression: build_spec produces stable section IDs
// ---------------------------------------------------------------------------
#[test]
fn magit_build_spec_section_ids_are_stable_canonical_set() {
let (state, _c, _u) = editor_with_magit();
let lua = state.lua_host.lua();
let ids: String = lua
.load(
r#"
local te = require("pmacs-magit").__pmacs_magit_test_seam_DO_NOT_USE
local spec = te.status.build_spec {
status = { staged = {}, unstaged = {}, untracked = {} },
log = {},
branches = { current = nil, all = {} },
stashes = {},
}
local out = {}
for _, s in ipairs(spec) do out[#out + 1] = s.id end
return table.concat(out, ",")
"#,
)
.eval()
.expect("build_spec ids");
assert_eq!(ids, "working,staged,log,branches,stashes");
}
// Isolated bootstrap storage roots (see the module docs): an
// integration test is compiled without `cfg(test)`, so a raw
// `EditorState::new()` would read the developer's real `init.lua` and
// write into their real data root.
#[path = "common/iso.rs"]
mod iso;