Files
2026-10-14 12:45:05 -03:00

264 lines
13 KiB
Rust

//! Real-git transitions and background snapshot regression tests.
use std::path::Path;
use std::time::{Duration, Instant};
use notescraft_git::{ChangeKind, Git};
use notescraft_ui_egui::source_panel::{Repository, Request, SourceState};
fn fixture(name: &str) -> Option<(std::path::PathBuf, Git)> {
if !notescraft_git::git_available() {
eprintln!("Skipping {name}: git is not installed");
return None;
}
let root = std::env::temp_dir().join(format!("notescraft-source-{name}-{}", std::process::id()));
assert!(std::fs::create_dir_all(&root).is_ok());
let git = Git::open(&root);
assert!(git.init().is_ok());
command(&root, &["config", "user.name", "NotesCraft tests"]);
command(&root, &["config", "user.email", "tests@example.invalid"]);
command(&root, &["config", "commit.gpgsign", "false"]);
assert!(std::fs::write(root.join("note.md"), "original\n").is_ok());
assert!(git.stage(&[".gitignore".into(), "note.md".into()]).is_ok());
assert!(git.commit("Initial note").is_ok());
Some((root, git))
}
fn command(root: &Path, args: &[&str]) {
let output = std::process::Command::new("git").arg("-C").arg(root).args(args).output();
assert!(output.as_ref().is_ok_and(|output| output.status.success()), "git {args:?}: {output:?}");
}
fn settle(state: &mut SourceState, now: f64) {
let deadline = Instant::now() + Duration::from_secs(10);
while state.busy && Instant::now() < deadline {
let _ = state.poll(now, false);
std::thread::sleep(Duration::from_millis(5));
}
assert!(!state.busy, "worker exceeded deadline");
assert!(state.error.is_none(), "{:?}", state.error);
}
fn request(id: &'static str, params: serde_json::Value) -> Request {
Request { id, params, current: None }
}
#[test]
fn worker_discovers_staged_unstaged_and_untracked_then_stages_unstages_and_commits() {
let Some((root, git)) = fixture("transitions") else { return };
assert!(std::fs::write(root.join("note.md"), "edited\n").is_ok());
assert!(std::fs::write(root.join("staged.md"), "staged\n").is_ok());
assert!(std::fs::write(root.join("new.md"), "new\n").is_ok());
assert!(git.stage(&["staged.md".into()]).is_ok());
let mut source = SourceState::new(git.clone(), false);
settle(&mut source, 0.0);
assert!(matches!(source.repository, Repository::Ready(_)), "repository not discovered");
let Repository::Ready(status) = &source.repository else { return };
assert_eq!(status.changes.iter().filter(|change| change.index != ChangeKind::None).count(), 1);
assert_eq!(status.changes.iter().filter(|change| change.worktree != ChangeKind::None).count(), 2);
assert!(source.submit(request("git.stage", serde_json::json!({"paths":["note.md","new.md"]}))).is_ok());
settle(&mut source, 1.0);
let status = git.status();
assert!(status.is_ok());
assert_eq!(status.ok().map(|status| status.changes.iter().filter(|change| change.index != ChangeKind::None).count()), Some(3));
assert!(source.submit(request("git.unstage", serde_json::json!({"paths":["new.md"]}))).is_ok());
settle(&mut source, 2.0);
assert_eq!(git.status().ok().map(|status| status.changes.iter().filter(|change| change.index != ChangeKind::None).count()), Some(2));
source.message = "Saved notes".into();
assert!(source.submit(request("git.commit", serde_json::json!({"message":"Saved notes"}))).is_ok());
settle(&mut source, 3.0);
assert_eq!(source.message, "");
assert_eq!(source.history.first().map(|commit| commit.subject.as_str()), Some("Saved notes"));
assert!(source.submit(request("git.diff", serde_json::json!({"path":"new.md","staged":false}))).is_ok());
settle(&mut source, 4.0);
assert_eq!(source.diff.as_ref().map(|selected| notescraft_ui_egui::diff_view::counts(&selected.diff)), Some((1, 0)));
drop(source);
let _ = std::fs::remove_dir_all(root);
}
#[test]
fn auto_snapshot_checks_disk_even_when_cached_status_was_clean() {
let Some((root, git)) = fixture("idle") else { return };
let mut source = SourceState::new(git.clone(), false);
settle(&mut source, 0.0);
assert_eq!(source.change_count(), 0);
source.auto_commit_minutes = 1;
assert!(std::fs::write(root.join("note.md"), "external edit\n").is_ok());
let _ = source.poll(59.0, false);
assert!(!source.busy);
let _ = source.poll(60.0, false);
assert!(source.busy, "idle deadline must check current disk changes");
settle(&mut source, 60.0);
let history = git.log(50, None);
assert!(history.is_ok());
let Ok(history) = history else { return };
assert_eq!(history.len(), 2);
assert!(history.first().is_some_and(|commit| commit.subject.starts_with("NotesCraft snapshot ")));
assert_eq!(source.change_count(), 0);
drop(source);
let _ = std::fs::remove_dir_all(root);
}
#[test]
fn read_only_worker_refuses_mutations() {
let Some((root, git)) = fixture("readonly") else { return };
let mut source = SourceState::new(git, true);
settle(&mut source, 0.0);
assert!(matches!(source.submit(request("git.init", serde_json::Value::Null)), Err(notescraft_ui_egui::source_panel::Error::ReadOnly)));
drop(source);
let _ = std::fs::remove_dir_all(root);
}
/// Centre of the header icon button in the 360 pt wide test panel.
const PANEL_RIGHT_ACTION_X: f32 = 336.0;
fn draw(
ctx: &egui::Context,
source: &mut SourceState,
events: Vec<egui::Event>,
) -> (notescraft_ui_egui::source_panel::SourceResponse, Vec<(String, egui::Pos2)>) {
let theme = notescraft_themes::builtin("catppuccin-mocha");
assert!(theme.is_some());
let Some(theme) = theme else { return (notescraft_ui_egui::source_panel::SourceResponse::default(), Vec::new()) };
let tokens = notescraft_ui_egui::theme::Tokens::new(theme);
let mut response = notescraft_ui_egui::source_panel::SourceResponse::default();
let mut output = ctx.run_ui(
egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(360.0, 900.0))), events, ..Default::default() },
|ui| {
response = notescraft_ui_egui::source_panel::draw(ui, &tokens, source, Some("note.md"));
},
);
let labels = output
.shapes
.iter()
.filter_map(|shape| {
let egui::Shape::Text(text) = &shape.shape else { return None };
Some((text.galley.text().to_owned(), text.pos + text.galley.size() / 2.0))
})
.collect();
output.textures_delta.clear();
(response, labels)
}
#[test]
fn stage_and_unstage_all_buttons_emit_correct_command_ids_and_paths() {
let Some((root, git)) = fixture("buttons") else { return };
assert!(std::fs::write(root.join("note.md"), "edited\n").is_ok());
assert!(std::fs::write(root.join("new.md"), "new\n").is_ok());
assert!(std::fs::write(root.join("staged.md"), "staged\n").is_ok());
assert!(git.stage(&["staged.md".into()]).is_ok());
let mut source = SourceState::new(git.clone(), false);
settle(&mut source, 0.0);
let ctx = egui::Context::default();
notescraft_ui_egui::fonts::install(&ctx);
egui_extras::install_image_loaders(&ctx);
let mut warm = ctx.run_ui(egui::RawInput::default(), |_| {});
warm.textures_delta.clear();
let (_, labels) = draw(&ctx, &mut source, Vec::new());
assert!(labels.iter().any(|(label, _)| label == "STAGED"));
assert!(labels.iter().any(|(label, _)| label == "CHANGES"));
for (label, command_id, expected_paths) in [("CHANGES", "git.stage", vec!["note.md", "new.md"]), ("STAGED", "git.unstage", vec!["staged.md"])] {
let (_, labels) = draw(&ctx, &mut source, Vec::new());
let position = labels.iter().find(|(text, _)| text == label).map(|(_, position)| *position);
assert!(position.is_some(), "{label}");
let Some(label_position) = position else { return };
// The all-button is an icon at the header's right edge, shown while the header is hovered.
let position = egui::pos2(PANEL_RIGHT_ACTION_X, label_position.y);
let _ = draw(&ctx, &mut source, vec![egui::Event::PointerMoved(label_position)]);
let events = vec![
egui::Event::PointerMoved(position),
egui::Event::PointerButton { pos: position, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE },
egui::Event::PointerButton { pos: position, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE },
];
let (response, _) = draw(&ctx, &mut source, events);
assert_eq!(response.request.as_ref().map(|request| request.id), Some(command_id));
let paths = response.request.and_then(|request| request.params.get("paths").cloned());
assert_eq!(paths, Some(serde_json::json!(expected_paths)));
}
drop(source);
let _ = std::fs::remove_dir_all(root);
}
#[cfg(unix)]
#[test]
fn paint_does_not_wait_for_git_worker_running_slow_commit_hook() {
use std::os::unix::fs::PermissionsExt;
let Some((root, git)) = fixture("paint") else { return };
let hook = root.join(".git/hooks/pre-commit");
assert!(std::fs::write(&hook, "#!/bin/sh\nsleep 1\n").is_ok());
assert!(std::fs::set_permissions(&hook, std::fs::Permissions::from_mode(0o755)).is_ok());
assert!(std::fs::write(root.join("note.md"), "changed\n").is_ok());
assert!(git.stage(&["note.md".into()]).is_ok());
let mut source = SourceState::new(git, false);
settle(&mut source, 0.0);
let ctx = egui::Context::default();
notescraft_ui_egui::fonts::install(&ctx);
egui_extras::install_image_loaders(&ctx);
let mut warm = ctx.run_ui(egui::RawInput::default(), |_| {});
warm.textures_delta.clear();
let start = Instant::now();
assert!(source.submit(request("git.commit", serde_json::json!({"message":"Slow background commit"}))).is_ok());
for _ in 0..8 {
let _ = draw(&ctx, &mut source, Vec::new());
}
assert!(start.elapsed() < Duration::from_millis(500), "paint waited for git");
assert!(source.busy);
settle(&mut source, 1.0);
drop(source);
let _ = std::fs::remove_dir_all(root);
}
#[test]
fn restored_versions_refresh_clean_buffers_and_preserve_dirty_conflicts() {
for dirty in [false, true] {
let Some((root, git)) = fixture(if dirty { "restore-dirty" } else { "restore-clean" }) else { return };
let history = git.log(1, None);
assert!(history.is_ok());
let Some(revision) = history.ok().and_then(|history| history.first().map(|commit| commit.hash.clone())) else { return };
assert!(std::fs::write(root.join("note.md"), "later version\n").is_ok());
assert!(git.stage(&["note.md".into()]).is_ok());
assert!(git.commit("Later version").is_ok());
let session = notescraft_engine::Session::open(&root);
assert!(session.is_ok());
let (Ok(mut session), Some(theme)) = (session, notescraft_themes::builtin("catppuccin-mocha")) else { return };
session.use_settings_dir(root.join(".notescraft/test-config"));
let mut app = notescraft_ui_egui::app::NotesApp::new(session, theme);
app.open_note("note.md");
if dirty {
assert!(app.session.buffer_mut("note.md").and_then(|buffer| buffer.edit(0..0, "unsaved ")).is_ok());
}
settle(&mut app.source, 0.0);
assert!(app.source.submit(request("git.restore_version", serde_json::json!({"path":"note.md","rev":revision}))).is_ok());
settle(&mut app.source, 1.0);
let deadline = Instant::now() + Duration::from_secs(3);
loop {
app.poll_vault_changes();
let updated = if dirty {
app.session.conflicts().iter().any(|path| path == "note.md")
} else {
app.session.buffer("note.md").is_some_and(|buffer| buffer.text() == "original\n")
};
if updated || Instant::now() >= deadline {
break;
}
std::thread::sleep(Duration::from_millis(10));
}
if dirty {
assert_eq!(app.session.buffer("note.md").map(notescraft_engine::Buffer::text), Some("unsaved later version\n"));
assert!(app.session.conflicts().iter().any(|path| path == "note.md"));
} else {
assert_eq!(
app.active_text(),
Some("original\n"),
"disk={:?} buffer={:?} toast={:?}",
std::fs::read_to_string(root.join("note.md")),
app.session.buffer("note.md").map(notescraft_engine::Buffer::text),
app.toast
);
assert_eq!(app.session.search("original", 20).ok().map(|hits| hits.len()), Some(1));
}
drop(app);
let _ = std::fs::remove_dir_all(root);
}
}