5e9b5d3cfb
Audio tab and note embeds with waveform, live spectrum (own FFT), seek, speed, loop and volume through ffplay/ffmpeg; code and text files open in an editable source editor with syntax highlighting, a Language chip and a banner to install, pick or ignore a grammar; ico decoding through the largest entry; info card as a clean grid; draw tools: arrows and lines clamped and clipped to the picture with a head, inline text tool, hotkeys; Activity tab with unread count and hotkey; calendar title shows a link icon and a pointing hand; explorer trash button works on press; right panel tab pills shrink so they never cover the collapse button; math: unknown command keeps a parser tooltip; test files grouped into tests/ folders (12-file rule); flaky search-order test waits for the rows. Built by Claude Sonnet and MiniMax M3.1 Flash; the whole tree verified with cargo xtask ci (fmt, clippy -D warnings, 4422 tests, layers, tree, i18n) on a copy of the working tree. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
664 lines
30 KiB
Rust
664 lines
30 KiB
Rust
//! Features driven the way a user does: real pointer and keyboard events through whole frames of the app.
|
||
use egui::{Context, Event, PointerButton, Pos2, RawInput};
|
||
use notescraft_engine::Session;
|
||
use notescraft_ui_egui::app::NotesApp;
|
||
use serde_json::json;
|
||
use std::sync::atomic::{AtomicU64, Ordering};
|
||
|
||
static NEXT: AtomicU64 = AtomicU64::new(0);
|
||
|
||
struct Driver {
|
||
app: NotesApp,
|
||
ctx: Context,
|
||
time: f64,
|
||
}
|
||
|
||
/// An app over a fresh vault holding `files`, with animations off.
|
||
fn driver(files: &[(&str, &str)]) -> Driver {
|
||
driver_with(files, &|_| {})
|
||
}
|
||
|
||
/// [`driver`] with `setup` run on the vault folder before the app opens it.
|
||
fn driver_with(files: &[(&str, &str)], setup: &dyn Fn(&std::path::Path)) -> Driver {
|
||
let root = std::env::temp_dir().join(format!("notescraft-driven-{}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed)));
|
||
std::fs::create_dir_all(&root).expect("root");
|
||
for (path, text) in files {
|
||
if let Some((folder, _)) = path.rsplit_once('/') {
|
||
std::fs::create_dir_all(root.join(folder)).expect("folder");
|
||
}
|
||
std::fs::write(root.join(path), text).expect("seed");
|
||
}
|
||
setup(&root);
|
||
let mut session = Session::open(&root).expect("session");
|
||
session.use_settings_dir(root.join("config"));
|
||
let mut app = NotesApp::new(session, notescraft_themes::builtin("catppuccin-mocha").expect("theme"));
|
||
app.state.animations = false;
|
||
Driver { app, ctx: Context::default(), time: 1.0 }
|
||
}
|
||
|
||
/// A painted text: what it says, where it starts and its galley (for the position of one character).
|
||
struct Painted {
|
||
text: String,
|
||
pos: Pos2,
|
||
galley: std::sync::Arc<egui::Galley>,
|
||
}
|
||
|
||
impl Driver {
|
||
/// One frame of 1440 x 900 with `events`; the texts it painted.
|
||
fn frame(&mut self, events: Vec<Event>) -> Vec<Painted> {
|
||
self.time += 0.05;
|
||
let input = RawInput {
|
||
screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, egui::vec2(1440.0, 900.0))),
|
||
time: Some(self.time),
|
||
events,
|
||
..Default::default()
|
||
};
|
||
let app = &mut self.app;
|
||
let mut out = self.ctx.run_ui(input, |ui| app.ui(ui.ctx()));
|
||
out.textures_delta.clear();
|
||
out.shapes
|
||
.iter()
|
||
.filter_map(|shape| match &shape.shape {
|
||
egui::Shape::Text(text) => Some(Painted { text: text.galley.text().to_owned(), pos: text.pos, galley: text.galley.clone() }),
|
||
_ => None,
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
fn settle(&mut self) -> Vec<Painted> {
|
||
let mut last = Vec::new();
|
||
for _ in 0..6 {
|
||
last = self.frame(Vec::new());
|
||
}
|
||
last
|
||
}
|
||
|
||
fn mouse(&mut self, at: Pos2, pressed: Option<bool>) -> Vec<Painted> {
|
||
let mut events = vec![Event::PointerMoved(at)];
|
||
if let Some(pressed) = pressed {
|
||
events.push(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE });
|
||
}
|
||
self.frame(events)
|
||
}
|
||
|
||
fn click(&mut self, at: Pos2) {
|
||
self.mouse(at, None);
|
||
self.mouse(at, Some(true));
|
||
self.mouse(at, Some(false));
|
||
self.settle();
|
||
}
|
||
}
|
||
|
||
fn names(texts: &[Painted]) -> Vec<&str> {
|
||
texts.iter().map(|painted| painted.text.as_str()).collect()
|
||
}
|
||
|
||
/// The centre of the character at `index` of a painted text.
|
||
fn char_at(painted: &Painted, index: usize) -> Pos2 {
|
||
let rect = painted.galley.pos_from_cursor(egui::text::CCursor::new(index));
|
||
painted.pos + rect.center().to_vec2()
|
||
}
|
||
|
||
fn find<'a>(texts: &'a [Painted], wanted: &str) -> &'a Painted {
|
||
texts.iter().find(|painted| painted.text == wanted).unwrap_or_else(|| panic!("no {wanted:?} among {:?}", names(texts)))
|
||
}
|
||
|
||
#[test]
|
||
fn dragging_a_tab_onto_another_reorders_the_tabs() {
|
||
let mut d = driver(&[("a.md", "A"), ("b.md", "B"), ("c.md", "C")]);
|
||
for path in ["a.md", "b.md", "c.md"] {
|
||
d.app.open_note(path);
|
||
}
|
||
let texts = d.settle();
|
||
let in_strip = |name: &str| texts.iter().find(|p| p.text == name && p.pos.y < 70.0 && p.pos.x > 300.0).unwrap_or_else(|| panic!("no tab {name:?}")).pos;
|
||
let (from, to) = (in_strip("a") + egui::vec2(6.0, 8.0), in_strip("c") + egui::vec2(6.0, 8.0));
|
||
d.mouse(from, None);
|
||
d.mouse(from, Some(true));
|
||
for step in 1..=6_u8 {
|
||
let at = from + (to - from) * (f32::from(step) / 6.0);
|
||
d.mouse(at, None);
|
||
}
|
||
d.mouse(to, Some(false));
|
||
d.settle();
|
||
assert_eq!(d.app.tabs.open, ["b.md", "c.md", "a.md"], "a dropped on c takes c's place");
|
||
}
|
||
|
||
#[test]
|
||
fn a_pinned_tab_goes_to_the_front_and_is_saved() {
|
||
let mut d = driver(&[("a.md", "A"), ("b.md", "B")]);
|
||
d.app.open_note("a.md");
|
||
d.app.open_note("b.md");
|
||
d.settle();
|
||
d.app.tabs.toggle_pin("b.md");
|
||
d.settle();
|
||
assert_eq!(d.app.tabs.open, ["b.md", "a.md"]);
|
||
d.app.persist_ui();
|
||
assert_eq!(d.app.state.pinned_tabs, ["b.md"]);
|
||
}
|
||
|
||
#[test]
|
||
fn resting_on_a_link_shows_the_page_preview_after_the_dwell() {
|
||
let mut d = driver(&[("a.md", "See [[b]] now\n"), ("b.md", "# B\nThe body of the second note.\n")]);
|
||
d.app.open_note("a.md");
|
||
let texts = d.settle();
|
||
let row = find(&texts, "See b now");
|
||
let on_link = char_at(row, 5);
|
||
d.mouse(on_link, None);
|
||
assert!(d.frame(Vec::new()).iter().all(|p| !p.text.contains("body of the second")), "no card at once");
|
||
let mut shown = false;
|
||
for _ in 0..14 {
|
||
shown |= d.mouse(on_link, None).iter().any(|p| p.text.contains("The body of the second note."));
|
||
}
|
||
assert!(shown, "the card appears once the pointer has rested on the link");
|
||
let away = d.mouse(Pos2::new(900.0, 700.0), None);
|
||
assert!(away.iter().all(|p| !p.text.contains("The body of the second note.")), "and goes when the pointer leaves");
|
||
}
|
||
|
||
#[test]
|
||
fn a_list_item_folds_its_children_from_the_gutter_chevron() {
|
||
let mut d = driver(&[("l.md", "- parent\n - child\n- other\n")]);
|
||
d.app.open_note("l.md");
|
||
let texts = d.settle();
|
||
assert!(texts.iter().any(|p| p.text.contains("child")));
|
||
let row = texts.iter().find(|p| p.text.contains("parent")).expect("parent row");
|
||
// The chevron sits at the right end of the 720 pt readable column (here x = 1080), 11 pt in.
|
||
let chevron = Pos2::new(1080.0 - 11.0, row.pos.y + 12.0);
|
||
d.mouse(chevron, None);
|
||
d.click(chevron);
|
||
let folded = d.settle();
|
||
assert!(folded.iter().all(|p| !p.text.contains("child")), "the child is hidden: {:?}", names(&folded));
|
||
assert!(folded.iter().any(|p| p.text.contains("other")), "siblings stay");
|
||
}
|
||
|
||
#[test]
|
||
fn splitting_shows_two_panes_and_clicking_the_second_makes_it_the_editor() {
|
||
let mut d = driver(&[("a.md", "# Alpha\n"), ("b.md", "# Beta\n")]);
|
||
d.app.open_note("a.md");
|
||
d.app.open_note("b.md");
|
||
d.app.run_command("view.split", &json!({}));
|
||
let texts = d.settle();
|
||
assert_eq!(texts.iter().filter(|p| p.text == "Beta").count(), 2, "the note is shown in both panes: {:?}", names(&texts));
|
||
assert_eq!(d.app.tabs.active_path(), Some("b.md"));
|
||
let right = texts.iter().filter(|p| p.text == "Beta").map(|p| p.pos).fold(Pos2::ZERO, |best, p| if p.x > best.x { p } else { best });
|
||
d.click(right + egui::vec2(60.0, 120.0));
|
||
assert_eq!(d.app.tabs.open, ["b.md"], "the clicked pane's tabs became the main tabs");
|
||
d.app.run_command("view.split", &json!({}));
|
||
let single = d.settle();
|
||
assert_eq!(single.iter().filter(|p| p.text == "Beta").count(), 1, "the command toggles the pane off");
|
||
}
|
||
|
||
#[test]
|
||
fn hidden_notes_appear_in_the_explorer_only_when_the_setting_is_on() {
|
||
let mut d = driver(&[("shown.md", "x"), (".stash/old.md", "y")]);
|
||
d.settle();
|
||
assert!(!d.app.tree.folder_paths().contains(".stash"));
|
||
d.app.run_command("settings.set", &json!({"key": "session.files.show_hidden_files", "value": true}));
|
||
d.settle();
|
||
assert!(d.app.tree.folder_paths().contains(".stash"));
|
||
}
|
||
|
||
#[test]
|
||
fn the_find_bar_counts_regular_expression_matches() {
|
||
let mut d = driver(&[("n.md", "x1 y22 z\n")]);
|
||
d.app.open_note("n.md");
|
||
d.settle();
|
||
d.app.open_find();
|
||
d.settle();
|
||
d.frame(vec![Event::Text(r"\d+".to_owned())]);
|
||
let plain = d.settle();
|
||
assert!(plain.iter().any(|p| p.text == "No matches"), "as plain text the pattern is not in the note: {:?}", names(&plain));
|
||
let switch = find(&plain, ".*").pos + egui::vec2(-14.0, 6.0);
|
||
d.click(switch);
|
||
let texts = d.settle();
|
||
assert!(texts.iter().any(|p| p.text == "1 of 2"), "{:?}", names(&texts));
|
||
}
|
||
|
||
#[test]
|
||
fn dragging_a_note_onto_a_folder_in_the_explorer_moves_the_file() {
|
||
let mut d = driver(&[("n.md", "# N\n"), ("dir/x.md", "# X\n")]);
|
||
d.app.run_command("explorer.reveal_active", &json!({}));
|
||
let texts = d.settle();
|
||
let row = |name: &str| {
|
||
texts.iter().find(|p| p.text == name && p.pos.x < 300.0 && p.pos.y > 250.0).unwrap_or_else(|| panic!("no row {name:?} in {:?}", names(&texts))).pos
|
||
};
|
||
let (from, to) = (row("n") + egui::vec2(8.0, 8.0), row("dir") + egui::vec2(8.0, 8.0));
|
||
d.mouse(from, None);
|
||
d.mouse(from, Some(true));
|
||
for step in 1..=8_u8 {
|
||
d.mouse(from + (to - from) * (f32::from(step) / 8.0), None);
|
||
}
|
||
d.mouse(to, Some(false));
|
||
d.settle();
|
||
d.settle();
|
||
let root = d.app.session.vault().root().to_path_buf();
|
||
assert!(root.join("dir/n.md").exists(), "moved into the folder");
|
||
assert!(!root.join("n.md").exists());
|
||
}
|
||
|
||
#[test]
|
||
fn a_video_embed_plays_inside_the_note_when_ffmpeg_is_installed() {
|
||
let made_ok = std::cell::Cell::new(false);
|
||
let mut d = driver_with(&[("v.md", "# V\n\n![[clip.mp4]]\n")], &|root| {
|
||
let status = std::process::Command::new("ffmpeg")
|
||
.args(["-y", "-loglevel", "error", "-f", "lavfi", "-i", "testsrc=duration=3:size=320x180:rate=24", "-pix_fmt", "yuv420p"])
|
||
.arg(root.join("clip.mp4"))
|
||
.status();
|
||
made_ok.set(status.is_ok_and(|status| status.success()));
|
||
});
|
||
if !made_ok.get() {
|
||
return;
|
||
}
|
||
d.app.open_note("v.md");
|
||
let texts = d.settle();
|
||
let play = find(&texts, "Play").pos + egui::vec2(8.0, 8.0);
|
||
d.click(play);
|
||
let mut frames_seen = 0;
|
||
for _ in 0..80 {
|
||
std::thread::sleep(std::time::Duration::from_millis(40));
|
||
d.frame(Vec::new());
|
||
if notescraft_ui_egui::app::video::state(&d.ctx).and_then(|state| state.playing).is_some() {
|
||
frames_seen += 1;
|
||
}
|
||
}
|
||
assert!(frames_seen > 5, "decoded frames reached the note: {frames_seen}");
|
||
}
|
||
|
||
#[test]
|
||
fn a_query_block_lists_the_matching_notes_and_follows_new_ones() {
|
||
let mut d = driver(&[("q.md", "# Q\n\n```query\nzebrafish\n```\n"), ("one.md", "a zebrafish here\n"), ("other.md", "nothing\n")]);
|
||
d.app.open_note("q.md");
|
||
let texts = d.settle();
|
||
assert!(texts.iter().any(|p| p.text == "one" && p.pos.x > 300.0), "the match is listed: {:?}", names(&texts));
|
||
assert!(texts.iter().all(|p| p.text != "other" || p.pos.x < 300.0), "a non-match is not");
|
||
d.app.run_command("note.create", &json!({"path": "two.md", "content": "zebrafish again\n"}));
|
||
let mut listed = false;
|
||
for _ in 0..60 {
|
||
std::thread::sleep(std::time::Duration::from_millis(50));
|
||
listed = d.frame(Vec::new()).iter().any(|p| p.text == "two" && p.pos.x > 300.0);
|
||
if listed {
|
||
break;
|
||
}
|
||
}
|
||
assert!(listed, "a note added later appears");
|
||
d.app.open_note("q.md");
|
||
let row = d.settle().iter().find(|p| p.text == "one" && p.pos.x > 300.0).expect("row").pos + egui::vec2(4.0, 6.0);
|
||
d.click(row);
|
||
assert_eq!(d.app.tabs.active_path(), Some("one.md"), "clicking a row opens the note");
|
||
}
|
||
|
||
#[test]
|
||
fn stacked_tabs_fold_the_others_into_strips_and_a_click_unfolds_one() {
|
||
let mut d = driver(&[("a.md", "# Alpha\n"), ("b.md", "# Beta\n"), ("c.md", "# Gamma\n")]);
|
||
for path in ["a.md", "b.md", "c.md"] {
|
||
d.app.open_note(path);
|
||
}
|
||
d.app.run_command("view.stacked_tabs", &json!({}));
|
||
let texts = d.settle();
|
||
assert!(texts.iter().any(|p| p.text == "Gamma"), "the active note is shown");
|
||
let strip = |texts: &[Painted], name: &str| texts.iter().find(|p| p.text == name && p.pos.x > 300.0 && p.pos.x < 420.0 && p.pos.y > 40.0).map(|p| p.pos);
|
||
let strip_a = strip(&texts, "a").expect("a strip titled a");
|
||
assert!(strip(&texts, "b").is_some(), "and one titled b");
|
||
assert!(strip(&texts, "c").is_none(), "the active tab has no strip");
|
||
d.click(strip_a + egui::vec2(4.0, -10.0));
|
||
assert_eq!(d.app.tabs.active_path(), Some("a.md"), "clicking a strip activates that tab");
|
||
let after = d.settle();
|
||
assert!(after.iter().any(|p| p.text == "Alpha"));
|
||
assert!(after.iter().any(|p| p.text == "c" && p.pos.x > 900.0 && p.pos.y > 40.0 && p.pos.y < 70.0), "the later tabs fold to the right of the note");
|
||
}
|
||
|
||
#[test]
|
||
fn ticking_a_task_inside_an_embed_changes_the_embedded_note() {
|
||
let mut d = driver(&[("host.md", "# Host\n\n![[tasks]]\n"), ("tasks.md", "- [ ] buy milk\n")]);
|
||
d.app.open_note("host.md");
|
||
let texts = d.settle();
|
||
let row = texts.iter().find(|p| p.text.contains("buy milk") && p.pos.x > 300.0).expect("the embedded task row").pos;
|
||
d.click(row + egui::vec2(8.0, 8.0));
|
||
d.settle();
|
||
let root = d.app.session.vault().root().to_path_buf();
|
||
let text = std::fs::read_to_string(root.join("tasks.md")).expect("tasks.md");
|
||
assert!(text.starts_with("- [x] buy milk"), "{text:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn text_on_the_line_of_an_embed_is_drawn_around_it() {
|
||
let mut d = driver(&[("host.md", "# Host\n\nbefore-word ![[side]] after-word\n"), ("side.md", "embedded body\n")]);
|
||
d.app.open_note("host.md");
|
||
let texts = d.settle();
|
||
let at = |needle: &str| texts.iter().find(|p| p.text.contains(needle) && p.pos.x > 300.0).map(|p| p.pos.y);
|
||
let (before, body, after) = (at("before-word"), at("embedded body"), at("after-word"));
|
||
assert!(before.is_some() && body.is_some() && after.is_some(), "{:?}", names(&texts));
|
||
assert!(before < body && body < after, "in order: {before:?} {body:?} {after:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn an_embed_follows_an_edit_made_to_the_embedded_note_in_its_own_tab() {
|
||
let mut d = driver(&[("host.md", "# Host\n\n![[side]]\n"), ("side.md", "old words\n")]);
|
||
d.app.open_note("side.md");
|
||
d.settle();
|
||
d.app.open_note("host.md");
|
||
assert!(d.settle().iter().any(|p| p.text.contains("old words")));
|
||
d.app.session.buffer_mut("side.md").expect("buffer").edit(0..3, "new").expect("edit");
|
||
let shown = d.settle();
|
||
assert!(shown.iter().any(|p| p.text.contains("new words")), "{:?}", names(&shown));
|
||
}
|
||
|
||
#[test]
|
||
fn the_block_menu_opens_from_the_keyboard_at_the_caret() {
|
||
let mut d = driver(&[("n.md", "first paragraph\n\nsecond paragraph\n")]);
|
||
d.app.open_note("n.md");
|
||
let texts = d.settle();
|
||
let row = find(&texts, "second paragraph").pos;
|
||
d.click(row + egui::vec2(20.0, 8.0));
|
||
assert!(notescraft_ui_egui::block_menu::current(&d.ctx).is_none(), "closed before the shortcut");
|
||
let key = Event::Key {
|
||
key: egui::Key::B,
|
||
physical_key: None,
|
||
pressed: true,
|
||
repeat: false,
|
||
modifiers: egui::Modifiers { alt: true, command: true, ..Default::default() },
|
||
};
|
||
d.frame(vec![key]);
|
||
d.settle();
|
||
assert!(notescraft_ui_egui::block_menu::current(&d.ctx).is_some(), "the menu is open; toast {:?}", d.app.toast);
|
||
}
|
||
|
||
#[test]
|
||
fn a_note_pops_out_of_the_tabs_and_comes_back_when_its_window_closes() {
|
||
let mut d = driver(&[("a.md", "# Alpha\n"), ("b.md", "# Beta body\n")]);
|
||
d.app.open_note("a.md");
|
||
d.app.open_note("b.md");
|
||
d.app.pop_out_for_test();
|
||
assert_eq!(d.app.tabs.open, ["a.md"], "the main window no longer lists the note");
|
||
assert!(!d.app.tabs.is_open("b.md"));
|
||
d.app.close_popouts_for_test();
|
||
assert!(d.app.tabs.is_open("b.md"), "closing the window returns the note");
|
||
assert_eq!(d.app.tabs.active_path(), Some("b.md"));
|
||
// Where the harness cannot open a native window, the note goes straight back to its tab.
|
||
d.app.pop_out_for_test();
|
||
d.settle();
|
||
assert!(d.app.tabs.is_open("b.md"));
|
||
}
|
||
|
||
#[test]
|
||
fn a_hebrew_paragraph_sits_against_the_right_edge_and_an_english_one_stays_left() {
|
||
let mut d = driver(&[("h.md", "# H\n\n\u{5e9}\u{5dc}\u{5d5}\u{5dd} \u{5e2}\u{5d5}\u{5dc}\u{5dd}\n\nplain english\n")]);
|
||
d.app.open_note("h.md");
|
||
let texts = d.settle();
|
||
let hebrew = texts.iter().find(|p| p.text.contains('\u{5dd}') && p.pos.x > 300.0 && p.pos.y > 100.0).expect("the hebrew row");
|
||
let english = find(&texts, "plain english");
|
||
let right_end = hebrew.pos.x + hebrew.galley.size().x;
|
||
assert!(right_end > english.pos.x + 400.0, "the hebrew row ends far to the right: {right_end} vs {}", english.pos.x);
|
||
assert!(hebrew.pos.x > english.pos.x + 300.0, "and starts well right of the left edge");
|
||
assert_eq!(hebrew.text.chars().next(), Some('\u{5dd}'), "painted in screen order: the last letter first");
|
||
}
|
||
|
||
fn double_click_folder(d: &mut Driver, name: &str) {
|
||
d.app.run_command("explorer.reveal_active", &json!({}));
|
||
let texts = d.settle();
|
||
let at =
|
||
texts.iter().find(|p| p.text == name && p.pos.x < 300.0 && p.pos.y > 250.0).unwrap_or_else(|| panic!("no row {name:?} in {:?}", names(&texts))).pos
|
||
+ egui::vec2(8.0, 8.0);
|
||
d.mouse(at, None);
|
||
for _ in 0..2 {
|
||
d.mouse(at, Some(true));
|
||
d.mouse(at, Some(false));
|
||
}
|
||
d.settle();
|
||
}
|
||
|
||
#[test]
|
||
fn a_folder_with_a_folder_note_opens_that_note_and_one_without_opens_the_folder_view() {
|
||
let mut d = driver(&[("proj/proj.md", "# Project home\n"), ("proj/other.md", "x\n"), ("plain/a.md", "y\n")]);
|
||
double_click_folder(&mut d, "proj");
|
||
assert_eq!(d.app.tabs.active_path(), Some("proj/proj.md"));
|
||
double_click_folder(&mut d, "plain");
|
||
assert_eq!(d.app.tabs.active_path().map(|path| path.starts_with(notescraft_ui_egui::tabs::FOLDER_PREFIX)), Some(true), "no folder note: the folder view");
|
||
}
|
||
|
||
#[test]
|
||
fn clicking_a_tag_in_a_note_searches_for_it() {
|
||
let mut d = driver(&[("t.md", "Plan the #garden work\n"), ("u.md", "more #garden\n")]);
|
||
d.app.open_note("t.md");
|
||
let texts = d.settle();
|
||
let row = find(&texts, "Plan the #garden work");
|
||
d.click(char_at(row, 11));
|
||
assert_eq!(d.app.search.query, "tag:#garden");
|
||
assert_eq!(d.app.state.sidebar_tab, notescraft_ui_egui::sidebar_tabs::SidebarTab::Search);
|
||
}
|
||
|
||
#[test]
|
||
fn the_explorer_calendar_folds_to_its_title_row_and_gives_the_room_to_the_tree() {
|
||
let files: Vec<(String, String)> = (0..30).map(|n| (format!("n{n:02}.md"), String::from("x\n"))).collect();
|
||
let refs: Vec<(&str, &str)> = files.iter().map(|(a, b)| (a.as_str(), b.as_str())).collect();
|
||
let mut d = driver(&refs);
|
||
let visible_notes = |texts: &[Painted]| texts.iter().filter(|p| p.text.starts_with('n') && p.text.len() == 3 && p.pos.x < 300.0 && p.pos.y > 250.0).count();
|
||
let before = d.settle();
|
||
assert!(before.iter().any(|p| p.text == "M"), "the calendar grid is drawn");
|
||
let rows_before = visible_notes(&before);
|
||
let month = before.iter().find(|p| p.text.ends_with("2026") && p.pos.x < 300.0).expect("the calendar title").pos;
|
||
d.click(month + egui::vec2(-20.0, 6.0));
|
||
let after = d.settle();
|
||
assert!(d.app.state.calendar_folded);
|
||
assert!(after.iter().all(|p| p.text != "M" || p.pos.x > 300.0), "the grid is gone");
|
||
assert!(after.iter().any(|p| p.text.ends_with("2026") && p.pos.x < 300.0), "the title row stays");
|
||
assert!(visible_notes(&after) > rows_before, "the tree gains rows: {rows_before} then {}", visible_notes(&after));
|
||
let folded_title = after.iter().find(|p| p.text.ends_with("2026") && p.pos.x < 300.0).expect("the title").pos;
|
||
d.click(folded_title + egui::vec2(-20.0, 6.0));
|
||
assert!(!d.app.state.calendar_folded, "and it unfolds again");
|
||
}
|
||
|
||
#[test]
|
||
fn the_performance_overlay_shows_frame_rate_memory_and_processor_use() {
|
||
let mut d = driver(&[("a.md", "# A\n")]);
|
||
d.settle();
|
||
assert!(d.settle().iter().all(|p| !p.text.contains(" fps")), "off by default");
|
||
d.app.run_command("view.performance", &json!({}));
|
||
let mut line = String::new();
|
||
for _ in 0..80 {
|
||
std::thread::sleep(std::time::Duration::from_millis(50));
|
||
line = d.frame(Vec::new()).iter().find(|p| p.text.contains(" fps")).map(|p| p.text.clone()).unwrap_or_default();
|
||
if cfg!(any(target_os = "macos", target_os = "linux")) && line.contains(" MB") {
|
||
break;
|
||
}
|
||
}
|
||
assert!(line.contains(" fps") && line.contains(" ms") && line.contains("CPU"), "{line:?}");
|
||
if cfg!(any(target_os = "macos", target_os = "linux")) {
|
||
assert!(line.contains(" MB"), "the process memory was read: {line:?}");
|
||
}
|
||
d.app.run_command("view.performance", &json!({}));
|
||
assert!(d.settle().iter().all(|p| !p.text.contains(" fps")), "and it goes away again");
|
||
}
|
||
|
||
#[test]
|
||
fn reading_mode_shows_the_note_but_a_click_and_typing_do_not_change_it() {
|
||
let mut d = driver(&[("r.md", "# R\n\nfirst paragraph\n")]);
|
||
d.app.open_note("r.md");
|
||
d.settle();
|
||
d.app.run_command("view.reading_mode", &json!({}));
|
||
let texts = d.settle();
|
||
let row = find(&texts, "first paragraph").pos + egui::vec2(20.0, 8.0);
|
||
d.click(row);
|
||
d.frame(vec![Event::Text("ZZZ".to_owned())]);
|
||
d.settle();
|
||
let text = d.app.session.buffer("r.md").map(|buffer| buffer.text().to_owned()).or_else(|| d.app.session.read_note("r.md").ok()).unwrap_or_default();
|
||
assert!(!text.contains("ZZZ"), "reading mode took the typing: {text:?}");
|
||
d.app.run_command("view.reading_mode", &json!({}));
|
||
d.settle();
|
||
let row = find(&d.settle(), "first paragraph").pos + egui::vec2(20.0, 8.0);
|
||
d.click(row);
|
||
d.frame(vec![Event::Text("ZZZ".to_owned())]);
|
||
d.settle();
|
||
let text = d.app.editing.tabs.get("r.md").map(|tab| tab.editor.text.clone()).unwrap_or_default();
|
||
assert!(text.contains("ZZZ"), "editing works again once reading mode is off: {text:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn the_trash_tab_lists_a_deleted_note_restores_it_and_empties_on_the_second_click() {
|
||
let mut d = driver(&[("keep.md", "# Keep\n"), ("gone.md", "# Gone\n"), ("also.md", "# Also\n")]);
|
||
d.app.run_command("note.delete", &json!({"path": "gone.md"}));
|
||
d.app.run_command("note.delete", &json!({"path": "also.md"}));
|
||
d.settle();
|
||
d.app.run_command("view.trash", &json!({}));
|
||
let texts = d.settle();
|
||
assert!(texts.iter().any(|p| p.text.starts_with("Trash")), "{:?}", names(&texts));
|
||
assert!(texts.iter().any(|p| p.text == "gone" || p.text == "also"), "the deleted notes are listed: {:?}", names(&texts));
|
||
let restore = texts.iter().filter(|p| p.text == "Restore").map(|p| p.pos).next().expect("a Restore button");
|
||
d.click(restore + egui::vec2(10.0, 6.0));
|
||
d.settle();
|
||
let root = d.app.session.vault().root().to_path_buf();
|
||
assert!(root.join("gone.md").exists() != root.join("also.md").exists(), "one of the two came back");
|
||
let empty = find(&d.settle(), "Empty trash").pos + egui::vec2(10.0, 6.0);
|
||
d.click(empty);
|
||
assert_eq!(d.app.session.trash_list().expect("list").len(), 1, "the first click only arms the button");
|
||
let confirm = find(&d.settle(), "Click again to erase").pos + egui::vec2(10.0, 6.0);
|
||
d.click(confirm);
|
||
d.settle();
|
||
assert_eq!(d.app.session.trash_list().expect("list").len(), 0, "the second click erased it");
|
||
}
|
||
|
||
#[test]
|
||
fn search_results_can_be_ordered_by_name_and_collapsed_to_titles() {
|
||
let mut d =
|
||
driver(&[("mid.md", "zebrafish zebrafish zebrafish zebrafish\n"), ("zed.md", "zebrafish zebrafish\n"), ("alpha.md", "a lonely zebrafish line\n")]);
|
||
d.app.state.sidebar_tab = notescraft_ui_egui::sidebar_tabs::SidebarTab::Search;
|
||
d.app.search.set_query("zebrafish".to_owned(), 0.0);
|
||
let order = |texts: &[Painted]| {
|
||
let mut rows: Vec<(f32, &str)> = texts
|
||
.iter()
|
||
.filter(|p| p.pos.x < 300.0 && matches!(p.text.as_str(), "mid.md" | "zed.md" | "alpha.md"))
|
||
.map(|p| (p.pos.y, p.text.as_str()))
|
||
.collect();
|
||
rows.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||
rows.into_iter().map(|(_, name)| name.to_owned()).collect::<Vec<_>>()
|
||
};
|
||
let mut texts = Vec::new();
|
||
// The search runs on a worker: pump frames until the expected rows show (a loaded machine needs longer than a fixed count).
|
||
for _ in 0..500 {
|
||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||
texts = d.frame(Vec::new());
|
||
if order(&texts).len() == 3 {
|
||
break;
|
||
}
|
||
}
|
||
let ranked = order(&texts);
|
||
assert_eq!(ranked.len(), 3, "{:?}", names(&texts));
|
||
assert_eq!(ranked.first().map(String::as_str), Some("mid.md"), "most matches first: {ranked:?}");
|
||
let pill = |texts: &[Painted], label: &str| find(texts, label).pos + egui::vec2(8.0, 6.0);
|
||
d.click(pill(&texts, "Relevance"));
|
||
// The search runs on a worker: pump frames until the expected rows show (a loaded machine needs longer than a fixed count).
|
||
for _ in 0..500 {
|
||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||
texts = d.frame(Vec::new());
|
||
if order(&texts) == ["alpha.md", "mid.md", "zed.md"] {
|
||
break;
|
||
}
|
||
}
|
||
assert_eq!(order(&texts), ["alpha.md", "mid.md", "zed.md"], "A to Z");
|
||
d.click(pill(&texts, "A to Z"));
|
||
// The search runs on a worker: pump frames until the expected rows show (a loaded machine needs longer than a fixed count).
|
||
for _ in 0..500 {
|
||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||
texts = d.frame(Vec::new());
|
||
if order(&texts) == ["zed.md", "mid.md", "alpha.md"] {
|
||
break;
|
||
}
|
||
}
|
||
assert_eq!(order(&texts), ["zed.md", "mid.md", "alpha.md"], "Z to A");
|
||
assert!(texts.iter().any(|p| p.text.contains("lonely")), "the matching lines show");
|
||
d.click(pill(&texts, "Collapse"));
|
||
let collapsed = d.settle();
|
||
assert!(collapsed.iter().all(|p| !p.text.contains("lonely")), "collapsed: only titles remain");
|
||
assert!(collapsed.iter().any(|p| p.text == "Expand"));
|
||
}
|
||
|
||
#[test]
|
||
fn a_table_gets_a_row_and_a_column_from_the_controls_under_it() {
|
||
let mut d = driver(&[("t.md", "# T\n\n| a | b |\n|---|---|\n| 1 | 2 |\n\nafter\n")]);
|
||
d.app.open_note("t.md");
|
||
let texts = d.settle();
|
||
d.click(find(&texts, "+ row").pos + egui::vec2(8.0, 6.0));
|
||
let text = |d: &Driver| d.app.editing.tabs.get("t.md").map(|tab| tab.editor.text.clone()).unwrap_or_default();
|
||
assert!(text(&d).contains("| 1 | 2 |\n| | |\nafter") || text(&d).contains("| 1 | 2 |\n| | |\n\nafter"), "{:?}", text(&d));
|
||
let texts = d.settle();
|
||
d.click(find(&texts, "+ column").pos + egui::vec2(8.0, 6.0));
|
||
assert!(text(&d).starts_with("# T\n\n| a | b | |\n| --- | --- | --- |\n| 1 | 2 | |\n"), "{:?}", text(&d));
|
||
let texts = d.settle();
|
||
d.click(find(&texts, "− row").pos + egui::vec2(8.0, 6.0));
|
||
let texts = d.settle();
|
||
d.click(find(&texts, "− column").pos + egui::vec2(8.0, 6.0));
|
||
assert!(text(&d).starts_with("# T\n\n| a | b |\n| --- | --- |\n| 1 | 2 |\n"), "{:?}", text(&d));
|
||
assert!(text(&d).ends_with("after\n"));
|
||
}
|
||
|
||
#[test]
|
||
fn a_heading_folds_its_section_in_the_source_view_from_the_margin_chevron() {
|
||
let text = "# One\nintro\n## Two\nbody two\nmore two\n## Three\nbody three\n";
|
||
let mut d = driver(&[("f.md", text)]);
|
||
d.app.open_note("f.md");
|
||
d.settle();
|
||
d.app.editing.set_view("f.md", notescraft_ui_egui::center::ViewMode::Markdown);
|
||
let buffer_height = |texts: &[Painted]| texts.iter().find(|p| p.text == text).map(|p| p.galley.size().y);
|
||
let before = d.settle();
|
||
let tall = buffer_height(&before).expect("the source buffer is painted");
|
||
let two = d.app.editing.tabs.get("f.md").and_then(|tab| tab.editor.text.find("## Two")).expect("heading");
|
||
let row = before.iter().find(|p| p.text == text).expect("buffer");
|
||
let at = row.pos + row.galley.pos_from_cursor(egui::text::CCursor::new(two)).center().to_vec2();
|
||
d.click(Pos2::new(row.pos.x - 18.0, at.y));
|
||
assert!(d.app.editing.tabs.get("f.md").is_some_and(|tab| tab.editor.folded.contains("## Two")), "the heading is folded");
|
||
let short = buffer_height(&d.settle()).expect("painted");
|
||
assert!(short < tall - 20.0, "the section's lines take no room: {tall} then {short}");
|
||
let again = d.settle();
|
||
let row = again.iter().find(|p| p.text == text).expect("buffer");
|
||
let at = row.pos + row.galley.pos_from_cursor(egui::text::CCursor::new(two)).center().to_vec2();
|
||
d.click(Pos2::new(row.pos.x - 18.0, at.y));
|
||
assert!(d.app.editing.tabs.get("f.md").is_some_and(|tab| tab.editor.folded.is_empty()), "a second click unfolds it");
|
||
assert_eq!(d.app.editing.tabs.get("f.md").map(|tab| tab.editor.text.as_str()), Some(text), "folding never changes the text");
|
||
}
|
||
|
||
#[test]
|
||
fn the_versions_tab_lists_a_snapshot_and_restores_it_over_the_current_text() {
|
||
let mut d = driver(&[("n.md", "first\n")]);
|
||
d.app.session.open_buffer("n.md").expect("buffer");
|
||
d.app.session.buffer_mut("n.md").expect("buffer").edit(0..5, "second").expect("edit");
|
||
d.app.session.save_buffer("n.md").expect("save");
|
||
d.app.session.close_buffer("n.md", true).expect("close");
|
||
d.app.open_note("n.md");
|
||
d.settle();
|
||
if let Some(tab) = d.app.editing.tabs.get_mut("n.md") {
|
||
tab.editor.text = String::from("changed\n");
|
||
}
|
||
d.app.run_command("view.note_versions", &json!({}));
|
||
let texts = d.settle();
|
||
assert!(texts.iter().any(|p| p.text.starts_with("Versions of")), "{:?}", names(&texts));
|
||
let compare = find(&texts, "Compare").pos + egui::vec2(10.0, 6.0);
|
||
d.click(compare);
|
||
assert!(d.app.source.diff.is_some(), "Compare opens the diff page");
|
||
d.app.source.diff = None;
|
||
d.app.run_command("view.note_versions", &json!({}));
|
||
let texts = d.settle();
|
||
d.click(find(&texts, "Restore").pos + egui::vec2(10.0, 6.0));
|
||
d.settle();
|
||
assert_eq!(d.app.editing.tabs.get("n.md").map(|tab| tab.editor.text.as_str()), Some("second\n"), "the snapshot is back in the editor");
|
||
}
|
||
|
||
#[test]
|
||
fn cmd_enter_in_an_open_visual_block_ticks_the_task() {
|
||
let mut d = driver(&[("t.md", "# T\n\n- [ ] buy milk\n")]);
|
||
d.app.open_note("t.md");
|
||
let texts = d.settle();
|
||
let row = texts.iter().find(|p| p.text.contains("buy milk") && p.pos.x > 300.0).expect("the task row").pos;
|
||
d.click(row + egui::vec2(60.0, 8.0));
|
||
d.settle();
|
||
let key = Event::Key {
|
||
key: egui::Key::Enter,
|
||
physical_key: None,
|
||
pressed: true,
|
||
repeat: false,
|
||
modifiers: egui::Modifiers { command: true, ..Default::default() },
|
||
};
|
||
d.frame(vec![key]);
|
||
d.settle();
|
||
let text = d.app.editing.tabs.get("t.md").map(|tab| tab.editor.text.clone()).unwrap_or_default();
|
||
assert!(text.contains("- [x] buy milk"), "{text:?}");
|
||
}
|