379 lines
14 KiB
Rust
379 lines
14 KiB
Rust
//! Editing the Bases table in place, driven headless over a 20-note vault: typed editors write the
|
|
//! note's frontmatter through `base.row.set`, selections copy and paste as TSV, Cmd+Z puts the old
|
|
//! value back, read-only cells and read-only vaults offer no editor, and the New button opens its row.
|
|
|
|
use std::path::PathBuf;
|
|
|
|
use egui::accesskit::Role;
|
|
use egui::{Event, Key, Modifiers, Rect, Vec2};
|
|
use egui_kittest::kittest::Queryable;
|
|
use notescraft_engine::{Registry, Session, register_core};
|
|
use notescraft_themes::builtin;
|
|
use notescraft_ui_egui::base_view::{self, BaseView};
|
|
use notescraft_ui_egui::theme::Tokens;
|
|
|
|
/// Columns of the fixture base, in order.
|
|
const COLUMNS: usize = 7;
|
|
const GENRE: usize = 1;
|
|
const YEAR: usize = 2;
|
|
const DONE: usize = 3;
|
|
const TAGS: usize = 4;
|
|
const DUE: usize = 5;
|
|
const SIZE: usize = 6;
|
|
|
|
const BASE: &str = "views:
|
|
- type: table
|
|
name: Notes
|
|
filters:
|
|
and:
|
|
- file.inFolder(\"Notes\")
|
|
order:
|
|
- file.name
|
|
- note.genre
|
|
- note.year
|
|
- note.done
|
|
- note.tags
|
|
- note.due
|
|
- file.size
|
|
";
|
|
|
|
struct Tab {
|
|
view: BaseView,
|
|
text: String,
|
|
tokens: Tokens,
|
|
session: Session,
|
|
registry: Registry<Session>,
|
|
writable: bool,
|
|
warmed: bool,
|
|
}
|
|
|
|
fn vault(name: &str) -> PathBuf {
|
|
let root = std::env::temp_dir().join(format!("notescraft-base-edit-{name}-{}", std::process::id()));
|
|
let _ = std::fs::remove_dir_all(&root);
|
|
std::fs::create_dir_all(root.join("Notes")).expect("notes folder");
|
|
for i in 0..20 {
|
|
let genre = if i % 2 == 0 { "scifi" } else { "crime" };
|
|
let text = format!("---\ngenre: {genre}\nyear: {}\ndone: false\ntags:\n - book\ndue: 2024-01-01\n---\nBook {i}\n", 1950 + i * 5);
|
|
std::fs::write(root.join(format!("Notes/note-{i:02}.md")), text).expect("note");
|
|
}
|
|
std::fs::write(root.join("Notes.base"), BASE).expect("base");
|
|
root
|
|
}
|
|
|
|
fn open(name: &str, writable: bool) -> (egui_kittest::Harness<'static, Tab>, PathBuf) {
|
|
let root = vault(name);
|
|
let session = if writable { Session::open(&root) } else { Session::open_read_only(&root) }.expect("the vault opens");
|
|
let mut registry = Registry::new();
|
|
register_core(&mut registry).expect("commands register");
|
|
let state = Tab {
|
|
view: BaseView::new("Notes.base"),
|
|
text: BASE.to_owned(),
|
|
tokens: Tokens::new(builtin("catppuccin-mocha").expect("theme")),
|
|
session,
|
|
registry,
|
|
writable,
|
|
warmed: false,
|
|
};
|
|
let mut h = egui_kittest::Harness::builder().with_size(Vec2::new(1500.0, 1000.0)).with_max_steps(300).build_ui_state(
|
|
|ui, tab: &mut Tab| {
|
|
if !tab.warmed {
|
|
notescraft_ui_egui::fonts::install(ui.ctx());
|
|
egui_extras::install_image_loaders(ui.ctx());
|
|
tab.warmed = true;
|
|
return;
|
|
}
|
|
base_view::show(ui, &tab.tokens, &mut tab.view, &mut tab.text, tab.writable);
|
|
let Tab { view, session, registry, .. } = tab;
|
|
if view.pump(&mut |id, params| registry.run(id, session, params).map_err(|error| error.to_string())) {
|
|
ui.ctx().request_repaint();
|
|
}
|
|
},
|
|
state,
|
|
);
|
|
for _ in 0..4 {
|
|
h.run();
|
|
}
|
|
(h, root)
|
|
}
|
|
|
|
/// The cell rectangles in row-major order.
|
|
fn cells(h: &egui_kittest::Harness<'static, Tab>) -> Vec<Rect> {
|
|
let mut rects: Vec<Rect> = h.get_all_by_role(Role::Cell).map(|n| n.rect()).collect();
|
|
rects.sort_by(|a, b| (a.top().round(), a.left()).partial_cmp(&(b.top().round(), b.left())).expect("finite"));
|
|
rects
|
|
}
|
|
|
|
fn cell(h: &egui_kittest::Harness<'static, Tab>, row: usize, col: usize) -> Rect {
|
|
cells(h)[row * COLUMNS + col]
|
|
}
|
|
|
|
fn click(h: &mut egui_kittest::Harness<'static, Tab>, row: usize, col: usize, modifiers: Modifiers) {
|
|
let at = cell(h, row, col).center();
|
|
h.input_mut().events.push(Event::ModifiersChanged(modifiers));
|
|
h.input_mut().events.push(Event::PointerMoved(at));
|
|
for pressed in [true, false] {
|
|
h.input_mut().events.push(Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers });
|
|
}
|
|
h.run();
|
|
h.run();
|
|
h.input_mut().events.push(Event::ModifiersChanged(Modifiers::NONE));
|
|
h.run();
|
|
}
|
|
|
|
fn double_click(h: &mut egui_kittest::Harness<'static, Tab>, row: usize, col: usize) {
|
|
let at = cell(h, row, col).center();
|
|
h.input_mut().events.push(Event::PointerMoved(at));
|
|
for _ in 0..2 {
|
|
for pressed in [true, false] {
|
|
h.input_mut().events.push(Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Modifiers::NONE });
|
|
}
|
|
}
|
|
h.run();
|
|
h.run();
|
|
}
|
|
|
|
fn press(h: &mut egui_kittest::Harness<'static, Tab>, modifiers: Modifiers, key: Key) {
|
|
h.input_mut().events.push(Event::ModifiersChanged(modifiers));
|
|
h.input_mut().events.push(Event::Key { key, physical_key: Some(key), pressed: true, repeat: false, modifiers });
|
|
h.input_mut().events.push(Event::Key { key, physical_key: Some(key), pressed: false, repeat: false, modifiers });
|
|
h.run();
|
|
h.input_mut().events.push(Event::ModifiersChanged(Modifiers::NONE));
|
|
h.run();
|
|
h.run();
|
|
}
|
|
|
|
fn type_text(h: &mut egui_kittest::Harness<'static, Tab>, text: &str) {
|
|
press(h, Modifiers::COMMAND, Key::A);
|
|
h.input_mut().events.push(Event::Text(text.to_owned()));
|
|
h.run();
|
|
}
|
|
|
|
fn note(root: &std::path::Path, i: usize) -> String {
|
|
std::fs::read_to_string(root.join(format!("Notes/note-{i:02}.md"))).expect("the note")
|
|
}
|
|
|
|
fn settle(h: &mut egui_kittest::Harness<'static, Tab>) {
|
|
for _ in 0..5 {
|
|
h.run();
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn editing_a_text_cell_writes_the_property_and_leaves_the_body() {
|
|
let (mut h, root) = open("text", true);
|
|
let before = note(&root, 0);
|
|
double_click(&mut h, 0, GENRE);
|
|
assert!(h.state().view.is_editing(), "a double click opens the editor");
|
|
type_text(&mut h, "western");
|
|
press(&mut h, Modifiers::NONE, Key::Enter);
|
|
settle(&mut h);
|
|
let after = note(&root, 0);
|
|
assert!(after.contains("genre: western"), "{after}");
|
|
assert_eq!(after.replace("genre: western", "genre: scifi"), before, "only that line changed, body identical");
|
|
assert!(!h.state().view.is_editing());
|
|
assert_eq!(h.state().view.cursor(), Some((1, GENRE)), "Enter moves the cursor down");
|
|
assert_eq!(h.state().view.data().and_then(|d| d.row(0)).and_then(|r| r.cells.get(GENRE)).and_then(|v| v.as_str()), Some("western"), "the row refreshed");
|
|
}
|
|
|
|
#[test]
|
|
fn enter_edits_the_cursor_cell_and_escape_cancels() {
|
|
let (mut h, root) = open("escape", true);
|
|
let before = note(&root, 0);
|
|
click(&mut h, 0, GENRE, Modifiers::NONE);
|
|
press(&mut h, Modifiers::NONE, Key::Enter);
|
|
assert!(h.state().view.is_editing());
|
|
type_text(&mut h, "never written");
|
|
press(&mut h, Modifiers::NONE, Key::Escape);
|
|
settle(&mut h);
|
|
assert!(!h.state().view.is_editing());
|
|
assert_eq!(note(&root, 0), before);
|
|
}
|
|
|
|
#[test]
|
|
fn a_checkbox_click_toggles_the_property() {
|
|
let (mut h, root) = open("checkbox", true);
|
|
click(&mut h, 2, DONE, Modifiers::NONE);
|
|
settle(&mut h);
|
|
assert!(note(&root, 2).contains("done: true"), "{}", note(&root, 2));
|
|
assert!(note(&root, 3).contains("done: false"));
|
|
click(&mut h, 2, DONE, Modifiers::NONE);
|
|
settle(&mut h);
|
|
assert!(note(&root, 2).contains("done: false"));
|
|
}
|
|
|
|
#[test]
|
|
fn a_number_that_is_not_one_is_refused_with_a_message() {
|
|
let (mut h, root) = open("number", true);
|
|
let before = note(&root, 0);
|
|
double_click(&mut h, 0, YEAR);
|
|
type_text(&mut h, "abc");
|
|
press(&mut h, Modifiers::NONE, Key::Enter);
|
|
settle(&mut h);
|
|
h.get_by_label_contains("is not a number");
|
|
assert!(h.state().view.is_editing(), "the editor stays open on a refused value");
|
|
assert_eq!(note(&root, 0), before);
|
|
type_text(&mut h, "1999");
|
|
press(&mut h, Modifiers::NONE, Key::Enter);
|
|
settle(&mut h);
|
|
assert!(note(&root, 0).contains("year: 1999"), "{}", note(&root, 0));
|
|
}
|
|
|
|
#[test]
|
|
fn an_invalid_date_is_refused_and_nothing_changes() {
|
|
let (mut h, root) = open("date", true);
|
|
let before = note(&root, 0);
|
|
double_click(&mut h, 0, DUE);
|
|
type_text(&mut h, "2024-02-31");
|
|
press(&mut h, Modifiers::NONE, Key::Enter);
|
|
settle(&mut h);
|
|
h.get_by_label_contains("is not a date");
|
|
assert_eq!(note(&root, 0), before);
|
|
}
|
|
|
|
#[test]
|
|
fn a_tags_cell_adds_a_normalised_chip() {
|
|
let (mut h, root) = open("tags", true);
|
|
click(&mut h, 1, TAGS, Modifiers::NONE);
|
|
press(&mut h, Modifiers::NONE, Key::Enter);
|
|
assert!(h.state().view.is_editing());
|
|
h.input_mut().events.push(Event::Text("#new tag".to_owned()));
|
|
h.run();
|
|
press(&mut h, Modifiers::NONE, Key::Enter);
|
|
h.get_by_label("Remove new-tag");
|
|
press(&mut h, Modifiers::NONE, Key::Enter);
|
|
settle(&mut h);
|
|
let after = note(&root, 1);
|
|
assert!(after.contains("tags: [book, new-tag]"), "{after}");
|
|
}
|
|
|
|
#[test]
|
|
fn file_properties_are_read_only() {
|
|
let (mut h, root) = open("readonly", true);
|
|
let before = note(&root, 0);
|
|
click(&mut h, 0, SIZE, Modifiers::NONE);
|
|
press(&mut h, Modifiers::NONE, Key::Enter);
|
|
assert_eq!(h.state().view.notice(), Some("File properties cannot be edited"));
|
|
double_click(&mut h, 0, SIZE);
|
|
assert!(!h.state().view.is_editing());
|
|
assert_eq!(note(&root, 0), before);
|
|
}
|
|
|
|
#[test]
|
|
fn a_read_only_vault_has_no_editors() {
|
|
let (mut h, root) = open("vault-readonly", false);
|
|
let before = note(&root, 0);
|
|
click(&mut h, 0, GENRE, Modifiers::NONE);
|
|
press(&mut h, Modifiers::NONE, Key::Enter);
|
|
double_click(&mut h, 0, YEAR);
|
|
click(&mut h, 0, DONE, Modifiers::NONE);
|
|
settle(&mut h);
|
|
assert!(!h.state().view.is_editing());
|
|
assert_eq!(note(&root, 0), before);
|
|
assert_eq!(h.state().view.cursor(), Some((0, DONE)), "cells still select");
|
|
}
|
|
|
|
#[test]
|
|
fn a_selection_copies_as_tsv() {
|
|
let (mut h, _root) = open("copy", true);
|
|
click(&mut h, 0, GENRE, Modifiers::NONE);
|
|
click(&mut h, 1, YEAR, Modifiers::SHIFT);
|
|
assert_eq!(h.state().view.selected_cells(), 4);
|
|
h.input_mut().events.push(Event::Copy);
|
|
h.run();
|
|
assert_eq!(h.state().view.last_copied(), "scifi\t1950\ncrime\t1955");
|
|
click(&mut h, 3, GENRE, Modifiers::COMMAND);
|
|
assert_eq!(h.state().view.selected_cells(), 5, "Cmd-click adds a cell");
|
|
click(&mut h, 3, GENRE, Modifiers::COMMAND);
|
|
assert_eq!(h.state().view.selected_cells(), 4, "and removes it again");
|
|
}
|
|
|
|
#[test]
|
|
fn pasting_a_block_writes_every_cell_and_cmd_z_takes_one_back() {
|
|
let (mut h, root) = open("paste", true);
|
|
click(&mut h, 0, GENRE, Modifiers::NONE);
|
|
h.input_mut().events.push(Event::Paste("western\t2001\nnoir\t2002".to_owned()));
|
|
h.run();
|
|
settle(&mut h);
|
|
assert!(note(&root, 0).contains("genre: western") && note(&root, 0).contains("year: 2001"), "{}", note(&root, 0));
|
|
assert!(note(&root, 1).contains("genre: noir") && note(&root, 1).contains("year: 2002"), "{}", note(&root, 1));
|
|
assert_eq!(h.state().view.notice(), Some("Pasted 4 cells"));
|
|
press(&mut h, Modifiers::COMMAND, Key::Z);
|
|
settle(&mut h);
|
|
assert!(note(&root, 1).contains("year: 1955"), "the last cell is back: {}", note(&root, 1));
|
|
assert!(note(&root, 1).contains("genre: noir"), "one step undoes one cell");
|
|
}
|
|
|
|
#[test]
|
|
fn a_paste_that_does_not_fit_writes_nothing() {
|
|
let (mut h, root) = open("paste-bad", true);
|
|
let before = note(&root, 19);
|
|
click(&mut h, 19, GENRE, Modifiers::NONE);
|
|
h.input_mut().events.push(Event::Paste("a\nb".to_owned()));
|
|
h.run();
|
|
settle(&mut h);
|
|
assert_eq!(note(&root, 19), before);
|
|
assert_eq!(h.state().view.notice(), Some("The pasted block does not fit in the table"));
|
|
}
|
|
|
|
#[test]
|
|
fn delete_clears_the_selected_cells() {
|
|
let (mut h, root) = open("clear", true);
|
|
click(&mut h, 0, GENRE, Modifiers::NONE);
|
|
press(&mut h, Modifiers::NONE, Key::Delete);
|
|
settle(&mut h);
|
|
assert!(!note(&root, 0).contains("genre"), "{}", note(&root, 0));
|
|
assert!(note(&root, 0).contains("year: 1950"));
|
|
}
|
|
|
|
#[test]
|
|
fn arrows_tab_and_paging_move_the_cursor() {
|
|
let (mut h, _root) = open("keys", true);
|
|
click(&mut h, 0, 0, Modifiers::NONE);
|
|
press(&mut h, Modifiers::NONE, Key::ArrowDown);
|
|
press(&mut h, Modifiers::NONE, Key::ArrowDown);
|
|
press(&mut h, Modifiers::NONE, Key::ArrowRight);
|
|
assert_eq!(h.state().view.cursor(), Some((2, 1)));
|
|
press(&mut h, Modifiers::NONE, Key::Tab);
|
|
assert_eq!(h.state().view.cursor(), Some((2, 2)));
|
|
press(&mut h, Modifiers::SHIFT, Key::Tab);
|
|
assert_eq!(h.state().view.cursor(), Some((2, 1)));
|
|
press(&mut h, Modifiers::NONE, Key::End);
|
|
assert_eq!(h.state().view.cursor(), Some((2, COLUMNS - 1)));
|
|
press(&mut h, Modifiers::NONE, Key::Home);
|
|
assert_eq!(h.state().view.cursor(), Some((2, 0)));
|
|
press(&mut h, Modifiers::NONE, Key::PageDown);
|
|
assert_eq!(h.state().view.cursor(), Some((19, 0)), "a page is longer than the 20 rows");
|
|
press(&mut h, Modifiers::NONE, Key::PageUp);
|
|
assert_eq!(h.state().view.cursor(), Some((0, 0)));
|
|
press(&mut h, Modifiers::COMMAND, Key::A);
|
|
assert_eq!(h.state().view.selected_cells(), 20 * COLUMNS);
|
|
}
|
|
|
|
#[test]
|
|
fn the_new_button_opens_the_first_editable_cell_of_its_row() {
|
|
let (mut h, root) = open("new", true);
|
|
h.get_by_label("New").click();
|
|
for _ in 0..8 {
|
|
h.run();
|
|
}
|
|
assert!(h.state().view.is_editing(), "the new row is being edited");
|
|
let (row, col) = h.state().view.cursor().expect("a cursor");
|
|
assert_eq!(col, 0);
|
|
let path = h.state().view.data().and_then(|d| d.row(row)).map(|r| r.path.clone()).expect("the new row");
|
|
assert!(root.join(&path).is_file(), "{path} exists");
|
|
assert_eq!(h.state().view.data().map(|d| d.total), Some(21));
|
|
}
|
|
|
|
#[test]
|
|
fn editing_the_file_name_renames_the_note() {
|
|
let (mut h, root) = open("rename", true);
|
|
click(&mut h, 0, 0, Modifiers::NONE);
|
|
press(&mut h, Modifiers::NONE, Key::Enter);
|
|
assert!(h.state().view.is_editing());
|
|
type_text(&mut h, "renamed");
|
|
press(&mut h, Modifiers::NONE, Key::Enter);
|
|
settle(&mut h);
|
|
assert!(root.join("Notes/renamed.md").is_file());
|
|
assert!(!root.join("Notes/note-00.md").exists());
|
|
}
|