d58d04fa01
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
264 lines
13 KiB
Rust
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);
|
|
}
|
|
}
|