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

252 lines
12 KiB
Rust

//! Source panel geometry and command gating without a GPU or repository writes.
use egui::{Context, Event, Pos2, RawInput, Shape};
use notescraft_git::{Change, ChangeKind, Git, Status};
use notescraft_ui_egui::{
source_panel::{self, Repository, SourceState},
theme::Tokens,
};
fn context() -> Context {
let ctx = Context::default();
notescraft_ui_egui::fonts::install(&ctx);
egui_extras::install_image_loaders(&ctx);
notescraft_ui_egui::motion::set_enabled(&ctx, false);
let mut warm = ctx.run_ui(RawInput::default(), |_| {});
warm.textures_delta.clear();
ctx
}
fn state(read_only: bool) -> SourceState {
let mut state = SourceState::new(Git::open(&std::env::temp_dir()), read_only);
state.busy = false;
state.repository = Repository::Ready(Status {
branch: Some("main".into()),
ahead: 2,
changes: vec![
Change { path: "staged.md".into(), orig_path: None, index: ChangeKind::Added, worktree: ChangeKind::None },
Change { path: "edited.md".into(), orig_path: None, index: ChangeKind::None, worktree: ChangeKind::Modified },
],
..Status::default()
});
state
}
fn frame(ctx: &Context, state: &mut SourceState, events: Vec<Event>) -> (source_panel::SourceResponse, Vec<Shape>) {
sized_frame(ctx, state, events, 360.0)
}
fn sized_frame(ctx: &Context, state: &mut SourceState, events: Vec<Event>, width: f32) -> (source_panel::SourceResponse, Vec<Shape>) {
let Some(theme) = notescraft_themes::builtin("catppuccin-mocha") else { panic!("built-in theme") };
let tokens = Tokens::new(theme);
notescraft_ui_egui::style::apply(ctx, &tokens);
let mut response = source_panel::SourceResponse::default();
let mut output =
ctx.run_ui(RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, egui::vec2(width, 900.0))), events, ..RawInput::default() }, |ui| {
response = source_panel::draw(ui, &tokens, state, Some("edited.md"));
});
output.textures_delta.clear();
(response, output.shapes.into_iter().map(|shape| shape.shape).collect())
}
fn text(shapes: &[Shape], label: &str) -> Option<egui::Rect> {
shapes.iter().find_map(|shape| {
let Shape::Text(text) = shape else { return None };
(text.galley.text() == label).then(|| egui::Rect::from_min_size(text.pos, text.galley.size()))
})
}
fn click(position: Pos2) -> Vec<Event> {
vec![
Event::PointerMoved(position),
Event::PointerButton { pos: position, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE },
Event::PointerButton { pos: position, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE },
]
}
#[test]
fn source_cards_precede_changes_and_show_both_branch_counts() {
let ctx = context();
let mut state = state(false);
let _ = frame(&ctx, &mut state, Vec::new());
let (_, shapes) = frame(&ctx, &mut state, Vec::new());
assert!(text(&shapes, "↑ 2").is_some());
assert!(text(&shapes, "↓ 0").is_some());
let commit = text(&shapes, "Commit 1 file");
let staged = text(&shapes, "STAGED");
assert!(commit.zip(staged).is_some_and(|(commit, staged)| commit.top() < staged.top()));
for height in [72.0, 44.0, 36.0] {
assert!(shapes.iter().any(|shape| matches!(shape, Shape::Rect(rect) if (rect.rect.height() - height).abs() < 0.1)), "missing {height} pt card");
}
}
#[test]
fn commit_card_gates_clicks_and_preserves_command_payload() {
let ctx = context();
let mut state = state(false);
let _ = frame(&ctx, &mut state, Vec::new());
let (_, shapes) = frame(&ctx, &mut state, Vec::new());
let Some(position) = text(&shapes, "Commit 1 file").map(|rect| rect.center()) else { panic!("commit card") };
assert!(frame(&ctx, &mut state, click(position)).0.request.is_none());
state.message = "Save notes".into();
let _ = frame(&ctx, &mut state, Vec::new());
let (response, _) = frame(&ctx, &mut state, click(position));
assert_eq!(response.request.as_ref().map(|request| request.id), Some("git.commit"));
assert_eq!(response.request.map(|request| request.params), Some(serde_json::json!({"message":"Save notes"})));
}
#[test]
fn commit_shortcut_keeps_message_and_emits_commit_request() {
let ctx = context();
let mut state = state(false);
state.message = "Save notes".into();
let _ = frame(&ctx, &mut state, Vec::new());
let (_, shapes) = frame(&ctx, &mut state, Vec::new());
let position = text(&shapes, "Save notes").unwrap().center();
let _ = frame(&ctx, &mut state, click(position));
let (response, _) = frame(
&ctx,
&mut state,
vec![Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::COMMAND }],
);
assert_eq!(response.request.as_ref().map(|request| request.id), Some("git.commit"));
assert_eq!(state.message, "Save notes");
}
#[test]
fn change_actions_preserve_payload_and_discard_requires_confirmation() {
let ctx = context();
let mut state = state(false);
let _ = frame(&ctx, &mut state, Vec::new());
let (_, shapes) = frame(&ctx, &mut state, Vec::new());
let edited = text(&shapes, "edited").unwrap().center();
let (response, _) = frame(&ctx, &mut state, click(edited));
assert_eq!(response.request.map(|request| (request.id, request.params)), Some(("git.diff", serde_json::json!({"path":"edited.md","staged":false}))));
let _ = frame(&ctx, &mut state, vec![Event::PointerMoved(edited)]);
let plus = egui::pos2(310.0, edited.y);
let (response, _) = frame(&ctx, &mut state, click(plus));
assert_eq!(response.request.map(|request| (request.id, request.params)), Some(("git.stage", serde_json::json!({"paths":["edited.md"]}))));
let discard = egui::pos2(338.0, edited.y);
let _ = frame(&ctx, &mut state, vec![Event::PointerMoved(discard)]);
let (response, _) = frame(&ctx, &mut state, click(discard));
assert!(response.request.is_none(), "discard must wait for confirmation");
let (_, shapes) = frame(&ctx, &mut state, Vec::new());
assert!(text(&shapes, "Discard changes from disk?").is_some());
let cancel = text(&shapes, "Cancel").unwrap().center();
let (response, _) = frame(&ctx, &mut state, click(cancel));
assert!(response.request.is_none());
let (_, shapes) = frame(&ctx, &mut state, Vec::new());
assert!(text(&shapes, "Cancel").is_none());
}
#[test]
fn staged_action_unstages_and_loading_line_tracks_busy_worker() {
let ctx = context();
let mut state = state(false);
let _ = frame(&ctx, &mut state, Vec::new());
let (_, shapes) = frame(&ctx, &mut state, Vec::new());
let staged = text(&shapes, "staged").unwrap().center();
let _ = frame(&ctx, &mut state, vec![Event::PointerMoved(staged)]);
let (response, _) = frame(&ctx, &mut state, click(egui::pos2(338.0, staged.y)));
assert_eq!(response.request.map(|request| (request.id, request.params)), Some(("git.unstage", serde_json::json!({"paths":["staged.md"]}))));
state.busy = true;
let (_, shapes) = frame(&ctx, &mut state, Vec::new());
let accent = notescraft_ui_egui::theme::color(notescraft_themes::builtin("catppuccin-mocha").unwrap().colors.accent);
assert!(shapes.iter().any(|shape| matches!(shape, Shape::Rect(rect) if (rect.rect.height() - 2.0).abs() < 0.01 && rect.fill == accent)));
let (response, _) = frame(&ctx, &mut state, click(egui::pos2(338.0, staged.y)));
assert!(response.request.is_none());
}
#[test]
fn source_controls_fit_design_width_and_read_only_rows_still_open_diff() {
let ctx = context();
let mut state = state(true);
let _ = sized_frame(&ctx, &mut state, Vec::new(), 252.0);
let (_, shapes) = sized_frame(&ctx, &mut state, Vec::new(), 252.0);
let caption = text(&shapes, "Read-only vault").unwrap();
let commit = text(&shapes, "Commit 1 file").unwrap();
assert!(caption.top() > commit.bottom());
assert!(text(&shapes, "Stage all").is_none());
assert!(text(&shapes, "Unstage all").is_none());
for rect in shapes.iter().filter_map(|shape| match shape {
Shape::Rect(rect) => Some(rect),
_ => None,
}) {
assert!(rect.rect.right() <= 252.1, "overflow: {:?}", rect.rect);
if [40.0, 72.0, 44.0].iter().any(|height| (rect.rect.height() - height).abs() < 0.01) {
assert_eq!(rect.corner_radius, egui::CornerRadius::same(12));
}
}
for name in ["main", "↑ 2", "↓ 0", "edited", "Commit 1 file", "Message (⌘↩ to commit)"] {
let rect = text(&shapes, name).unwrap();
assert!(rect.right() <= 252.1, "text overflow: {name} {rect:?}");
}
let position = text(&shapes, "edited").unwrap().center();
let (response, _) = sized_frame(&ctx, &mut state, click(position), 252.0);
assert_eq!(response.request.as_ref().map(|request| request.id), Some("git.diff"));
}
#[test]
fn history_rows_show_metadata_and_open_note_comparison() {
let ctx = context();
let mut state = state(false);
state.history = vec![notescraft_git::Commit {
hash: "a1b2c3d456".into(),
author: "Marcos".into(),
date: "2026-01-01T10:00:00+00:00".into(),
subject: "Add roadmap".into(),
}];
let _ = frame(&ctx, &mut state, Vec::new());
let (_, shapes) = frame(&ctx, &mut state, Vec::new());
let heading = text(&shapes, "HISTORY").unwrap();
let vault = text(&shapes, "Vault").unwrap();
let note = text(&shapes, "Active note").unwrap();
assert!(vault.top() > heading.bottom());
assert!((vault.center().y - note.center().y).abs() < 0.1);
assert!(shapes.iter().any(|shape| matches!(shape, Shape::Text(text) if text.galley.text().starts_with("Marcos · ") && text.galley.text().ends_with(" · a1b2c3d") && (text.galley.job.sections.first().unwrap().format.font_id.size - 12.0).abs() < 0.01)));
let subject = text(&shapes, "Add roadmap").unwrap();
let (response, _) = frame(&ctx, &mut state, click(subject.center()));
assert_eq!(response.history, Some(("edited.md".into(), "a1b2c3d456".into())));
}
#[test]
fn valid_commit_keeps_full_accent_and_changes_have_design_pitch() {
let ctx = context();
let mut state = state(false);
state.message = "Save notes".into();
if let Repository::Ready(status) = &mut state.repository {
status.changes.push(Change { path: "another.md".into(), orig_path: None, index: ChangeKind::Added, worktree: ChangeKind::None });
}
let _ = frame(&ctx, &mut state, Vec::new());
let (_, shapes) = frame(&ctx, &mut state, Vec::new());
let accent = notescraft_ui_egui::theme::color(notescraft_themes::builtin("catppuccin-mocha").unwrap().colors.accent);
assert!(shapes.iter().any(|shape| matches!(shape, Shape::Rect(rect) if (rect.rect.height() - 44.0).abs() < 0.1 && rect.fill == accent)));
assert!(text(&shapes, "Read-only vault").is_none());
let first = text(&shapes, "staged").unwrap();
assert!((first.left() - 36.0).abs() < 0.1);
let second = text(&shapes, "another").unwrap();
assert!((second.center().y - first.center().y - 48.0).abs() < 0.1);
}
#[test]
fn repository_empty_states_use_muted_cards_and_init_is_gated() {
let ctx = context();
let mut state = state(true);
for repository in [Repository::MissingGit, Repository::NotInitialized] {
state.repository = repository;
let _ = frame(&ctx, &mut state, Vec::new());
let (_, shapes) = frame(&ctx, &mut state, Vec::new());
let surface = notescraft_ui_egui::theme::color(notescraft_themes::builtin("catppuccin-mocha").unwrap().colors.surface_2);
assert!(shapes.iter().any(|shape| matches!(shape, Shape::Rect(rect) if rect.fill == surface && rect.rect.height() > 36.0)));
}
let (_, shapes) = frame(&ctx, &mut state, Vec::new());
let initialize = text(&shapes, "Initialize repository").unwrap().center();
assert!(frame(&ctx, &mut state, click(initialize)).0.request.is_none());
let mut writable = self::state(false);
writable.repository = Repository::NotInitialized;
let _ = frame(&ctx, &mut writable, Vec::new());
let (_, shapes) = frame(&ctx, &mut writable, Vec::new());
let initialize = text(&shapes, "Initialize repository").unwrap().center();
assert_eq!(frame(&ctx, &mut writable, click(initialize)).0.request.as_ref().map(|request| request.id), Some("git.init"));
}