b9d21de5b2
.base files open as a tab: view switcher (add, duplicate, rename, delete, reorder), toolbar, status line, a raw-YAML fallback card with the parse-error line; virtualised table (only visible rows painted, pages fetched on demand) with sticky header, sort arrows, resizable columns persisted to columnSize, grouping with counts and folding, summary row, typed cells, links that open notes; Properties popover (search, reorder, formulas), Sort and Group menus and a filter builder that round-trips the docs' expression forms (Bases tasks 14-16, Claude Sonnet). Maps tile service: https-only fetcher with the project User-Agent, 5 s timeout, 256 KiB cap, image/png check, burst 8 then 1 request per second and 2 in flight, disk cache with a 7-day floor and size cap, maps.status / maps.clear_cache / maps.tile commands, maps.* settings off by default, nothing runs while disabled (task 12, MiniMax M3.1 Flash, OpenCode Zen free route). Verified with the full cargo xtask ci on a copy of the working tree (4572 tests, clippy -D warnings, layers, tree, i18n). Gaps: no Summarize menu, no cards/list/map views yet, view edits bypass base.view.*. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
350 lines
13 KiB
Rust
350 lines
13 KiB
Rust
//! The Bases tab, driven headless: it opens a base over a fixture vault, switches and adds views,
|
|
//! shows the raw-text card with the error line for a broken file, sorts from the header, keeps a
|
|
//! resized column, paints only the rows in view of a 15 000-row answer, toggles a column in the
|
|
//! Properties popover and rewrites the query from the filter builder.
|
|
|
|
use egui::{Event, PointerButton, Pos2, Vec2};
|
|
use egui_kittest::kittest::Queryable;
|
|
use notescraft_bases::Base;
|
|
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;
|
|
use serde_json::{Value, json};
|
|
|
|
type Backend = Box<dyn FnMut(&str, &Value) -> Result<Value, String>>;
|
|
|
|
struct Tab {
|
|
view: BaseView,
|
|
text: String,
|
|
tokens: Tokens,
|
|
session: Option<Session>,
|
|
registry: Registry<Session>,
|
|
fake: Option<Backend>,
|
|
warmed: bool,
|
|
edits: usize,
|
|
opened: Vec<String>,
|
|
}
|
|
|
|
const BOOKS: &str = "views:
|
|
- type: table
|
|
name: Reading list
|
|
filters:
|
|
and:
|
|
- file.inFolder(\"Books\")
|
|
order:
|
|
- file.name
|
|
- note.year
|
|
- type: table
|
|
name: Everything
|
|
";
|
|
|
|
fn vault(name: &str) -> std::path::PathBuf {
|
|
let root = std::env::temp_dir().join(format!("notescraft-base-view-{name}-{}", std::process::id()));
|
|
let _ = std::fs::remove_dir_all(&root);
|
|
std::fs::create_dir_all(root.join("Books")).expect("books folder");
|
|
std::fs::create_dir_all(root.join("Other")).expect("other folder");
|
|
for i in 0..15 {
|
|
let genre = if i % 2 == 0 { "scifi" } else { "crime" };
|
|
std::fs::write(root.join(format!("Books/book-{i:02}.md")), format!("---\nyear: {}\ngenre: {genre}\ntags: [book]\n---\nBook {i}\n", 1950 + i * 5))
|
|
.expect("book");
|
|
}
|
|
for i in 0..5 {
|
|
std::fs::write(root.join(format!("Other/note-{i}.md")), format!("---\nyear: {}\n---\nNote\n", 2000 + i)).expect("note");
|
|
}
|
|
std::fs::write(root.join("Books.base"), BOOKS).expect("base");
|
|
root
|
|
}
|
|
|
|
fn tab(name: &str, text: &str) -> egui_kittest::Harness<'static, Tab> {
|
|
let root = vault(name);
|
|
let session = Session::open(&root).expect("the vault opens");
|
|
let mut registry = Registry::new();
|
|
register_core(&mut registry).expect("commands register");
|
|
harness(Tab {
|
|
view: BaseView::new("Books.base"),
|
|
text: text.to_owned(),
|
|
tokens: Tokens::new(builtin("catppuccin-mocha").expect("theme")),
|
|
session: Some(session),
|
|
registry,
|
|
fake: None,
|
|
warmed: false,
|
|
edits: 0,
|
|
opened: Vec::new(),
|
|
})
|
|
}
|
|
|
|
fn harness(state: Tab) -> egui_kittest::Harness<'static, Tab> {
|
|
egui_kittest::Harness::builder().with_size(Vec2::new(1000.0, 700.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;
|
|
}
|
|
let shown = base_view::show(ui, &tab.tokens, &mut tab.view, &mut tab.text, true);
|
|
tab.edits += usize::from(shown.edited);
|
|
tab.opened.extend(shown.open.map(|link| link.target));
|
|
let Tab { view, session, registry, fake, .. } = tab;
|
|
let ran = view.pump(&mut |id, params| match (fake.as_mut(), session.as_mut()) {
|
|
(Some(fake), _) => fake(id, params),
|
|
(None, Some(session)) => registry.run(id, session, params).map_err(|error| error.to_string()),
|
|
(None, None) => Err(String::new()),
|
|
});
|
|
if ran {
|
|
ui.ctx().request_repaint();
|
|
}
|
|
},
|
|
state,
|
|
)
|
|
}
|
|
|
|
fn views_of(text: &str) -> Vec<(String, usize)> {
|
|
let base = Base::parse(text).expect("the text parses");
|
|
base.views().iter().map(|v| (v.name.clone().unwrap_or_default(), v.order.len())).collect()
|
|
}
|
|
|
|
#[test]
|
|
fn opens_a_base_and_queries_it() {
|
|
let mut h = tab("open", BOOKS);
|
|
h.run();
|
|
h.run();
|
|
let data = h.state().view.data().expect("the query answered");
|
|
assert_eq!(data.total, 15, "the folder filter keeps the books");
|
|
assert_eq!(data.columns.iter().map(|c| c.id.as_str()).collect::<Vec<_>>(), ["file.name", "note.year"]);
|
|
assert_eq!(data.row(0).map(|r| r.path.as_str()), Some("Books/book-00.md"));
|
|
assert!(h.state().view.broken().is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn switches_and_adds_views_from_the_menu() {
|
|
let mut h = tab("views", BOOKS);
|
|
h.run();
|
|
h.get_by_label("Reading list").click();
|
|
h.run();
|
|
h.get_by_label("Everything").click();
|
|
h.run();
|
|
h.run();
|
|
assert_eq!(h.state().view.view, 1);
|
|
assert!(h.state().view.data().is_some_and(|d| d.total >= 20), "the second view shows every note");
|
|
h.get_all_by_label("Everything").next().expect("the switcher").click();
|
|
h.run();
|
|
h.get_by_label("Add view").click();
|
|
h.run();
|
|
assert_eq!(views_of(&h.state().text).len(), 3);
|
|
assert_eq!(h.state().view.view, 2, "the new view is shown");
|
|
assert!(h.state().edits > 0, "the shell is told to save");
|
|
}
|
|
|
|
#[test]
|
|
fn a_broken_file_shows_the_card_with_a_line() {
|
|
let mut h = tab("broken", "views:\n - type: table\n\t name: x\n");
|
|
h.run();
|
|
let broken = h.state().view.broken().expect("the text does not parse").clone();
|
|
assert_eq!(broken.line, Some(3), "{broken:?}");
|
|
h.get_by_label_contains("Line 3");
|
|
assert!(h.query_by_label("Reading list").is_none(), "no toolbar over a broken file");
|
|
assert!(h.state().view.data().is_none());
|
|
}
|
|
|
|
fn sort(text: &str) -> Vec<(String, String)> {
|
|
Base::parse(text).expect("parses").views()[0].sort.iter().map(|s| (s.property.clone(), format!("{:?}", s.direction))).collect()
|
|
}
|
|
|
|
#[test]
|
|
fn a_header_click_sorts_and_a_second_one_flips() {
|
|
let mut h = tab("sort", BOOKS);
|
|
h.run();
|
|
h.get_by_label("year").click();
|
|
h.run();
|
|
h.run();
|
|
assert_eq!(sort(&h.state().text), [("note.year".to_owned(), "Asc".to_owned())]);
|
|
assert_eq!(h.state().view.data().and_then(|d| d.row(0)).map(|r| r.path.clone()).as_deref(), Some("Books/book-00.md"));
|
|
h.get_by_label("year").click();
|
|
h.run();
|
|
h.run();
|
|
assert_eq!(sort(&h.state().text), [("note.year".to_owned(), "Desc".to_owned())]);
|
|
assert_eq!(h.state().view.data().and_then(|d| d.row(0)).map(|r| r.path.clone()).as_deref(), Some("Books/book-14.md"));
|
|
}
|
|
|
|
#[test]
|
|
fn a_resized_column_is_kept_in_the_file() {
|
|
let mut h = tab("resize", BOOKS);
|
|
h.run();
|
|
let grab = h.get_by_label("Resize year").rect().center();
|
|
h.input_mut().events.push(Event::PointerMoved(grab));
|
|
h.input_mut().events.push(Event::PointerButton { pos: grab, button: PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE });
|
|
h.run();
|
|
h.input_mut().events.push(Event::PointerMoved(grab + Vec2::new(60.0, 0.0)));
|
|
h.run();
|
|
h.input_mut().events.push(Event::PointerButton {
|
|
pos: grab + Vec2::new(60.0, 0.0),
|
|
button: PointerButton::Primary,
|
|
pressed: false,
|
|
modifiers: egui::Modifiers::NONE,
|
|
});
|
|
h.run();
|
|
h.run();
|
|
let base = Base::parse(&h.state().text).expect("parses");
|
|
let width = notescraft_ui_egui::base_view::model_edit::stored_width(&base.views()[0], "note.year").expect("columnSize holds the year");
|
|
assert!((width - 240.0).abs() < 8.0, "dragged 60 points from 180: {width}");
|
|
}
|
|
|
|
/// A fake engine answering `base.query` with `total` rows of two cells.
|
|
fn fake_rows(total: usize, calls: std::rc::Rc<std::cell::Cell<usize>>) -> Backend {
|
|
Box::new(move |id, params| {
|
|
calls.set(calls.get() + 1);
|
|
if id != "base.query" {
|
|
return Ok(json!({ "properties": [] }));
|
|
}
|
|
let at = |name: &str| params[name].as_u64().and_then(|n| usize::try_from(n).ok()).unwrap_or(0);
|
|
let (offset, limit) = (at("offset"), at("limit"));
|
|
let rows: Vec<Value> = (offset..total.min(offset + limit))
|
|
.map(|i| json!({ "path": format!("n/{i}.md"), "group": 0, "cells": [{ "file": format!("n/{i}.md") }, i] }))
|
|
.collect();
|
|
Ok(json!({
|
|
"columns": [{ "id": "file.name", "name": "name" }, { "id": "note.n", "name": "n", "summary": "Sum" }],
|
|
"total": total, "errors": 0, "summaries": [null, 1], "groups": [{ "key": null, "count": total, "summaries": [] }], "rows": rows,
|
|
}))
|
|
})
|
|
}
|
|
|
|
#[test]
|
|
fn fifteen_thousand_rows_paint_a_bounded_window() {
|
|
let calls = std::rc::Rc::new(std::cell::Cell::new(0));
|
|
let state = Tab {
|
|
view: BaseView::new("Big.base"),
|
|
text: BOOKS.to_owned(),
|
|
tokens: Tokens::new(builtin("catppuccin-mocha").expect("theme")),
|
|
session: None,
|
|
registry: Registry::new(),
|
|
fake: Some(fake_rows(15_000, calls.clone())),
|
|
warmed: false,
|
|
edits: 0,
|
|
opened: Vec::new(),
|
|
};
|
|
let mut h = harness(state);
|
|
h.run();
|
|
h.run();
|
|
assert_eq!(h.state().view.data().map(|d| d.total), Some(15_000));
|
|
let painted = h.state().view.rows_painted();
|
|
assert!((1..=40).contains(&painted), "first screen paints {painted} rows");
|
|
for _ in 0..12 {
|
|
h.input_mut().events.push(Event::PointerMoved(Pos2::new(400.0, 300.0)));
|
|
h.input_mut().events.push(Event::MouseWheel {
|
|
unit: egui::MouseWheelUnit::Line,
|
|
delta: Vec2::new(0.0, -300.0),
|
|
modifiers: egui::Modifiers::NONE,
|
|
phase: egui::TouchPhase::Move,
|
|
});
|
|
h.run();
|
|
assert!(h.state().view.rows_painted() <= 40, "painted {}", h.state().view.rows_painted());
|
|
}
|
|
h.run();
|
|
let data = h.state().view.data().expect("data");
|
|
assert!(data.pages.len() < 30, "pages are fetched on demand: {}", data.pages.len());
|
|
assert!(data.pages.keys().any(|page| *page > 0), "scrolling asked for a later page");
|
|
assert!(calls.get() < 40, "queries stay few: {}", calls.get());
|
|
}
|
|
|
|
#[test]
|
|
fn the_properties_popover_shows_and_hides_columns() {
|
|
let mut h = tab("props", BOOKS);
|
|
h.run();
|
|
h.get_by_label("Properties").click();
|
|
h.run();
|
|
h.run();
|
|
h.get_by_label("genre").click();
|
|
h.run();
|
|
h.run();
|
|
assert_eq!(views_of(&h.state().text)[0].1, 3, "genre joined the order: {}", h.state().text);
|
|
assert_eq!(h.state().view.data().map(|d| d.columns.len()), Some(3));
|
|
h.get_by_role_and_label(egui::accesskit::Role::CheckBox, "year").click();
|
|
h.run();
|
|
h.run();
|
|
assert_eq!(views_of(&h.state().text)[0].1, 2, "year left the order: {}", h.state().text);
|
|
}
|
|
|
|
#[test]
|
|
fn the_filter_builder_adds_a_row_and_the_query_changes() {
|
|
let mut h = tab("filter", BOOKS);
|
|
h.run();
|
|
assert_eq!(h.state().view.data().map(|d| d.total), Some(15));
|
|
h.get_by_label("Filter").click();
|
|
h.run();
|
|
h.get_by_label("This view").click();
|
|
h.run();
|
|
h.get_by_label("Add filter").click();
|
|
h.run();
|
|
h.run();
|
|
assert!(h.state().text.contains("file.name != \"\""), "{}", h.state().text);
|
|
let value = h.get_all_by_role(egui::accesskit::Role::TextInput).last().expect("the value box");
|
|
value.focus();
|
|
value.type_text("book-01.md");
|
|
h.run();
|
|
h.run();
|
|
h.run();
|
|
assert!(h.state().text.contains("file.name != \"book-01.md\""), "{}", h.state().text);
|
|
assert_eq!(h.state().view.data().map(|d| d.total), Some(14));
|
|
}
|
|
|
|
/// Renders the tab to PNGs for a visual check: `NOTESCRAFT_BASE_PNG_DIR=<dir> cargo test --test base_view -- --ignored`.
|
|
#[test]
|
|
#[ignore = "writes PNGs for a visual check"]
|
|
fn render_pngs() {
|
|
let Ok(dir) = std::env::var("NOTESCRAFT_BASE_PNG_DIR") else { return };
|
|
let text = "views:
|
|
- type: table
|
|
name: Reading list
|
|
filters:
|
|
and:
|
|
- file.inFolder(\"Books\")
|
|
order:
|
|
- file.name
|
|
- note.year
|
|
- note.genre
|
|
- file.tags
|
|
sort:
|
|
- property: note.year
|
|
direction: DESC
|
|
summaries:
|
|
note.year: Average
|
|
- type: table
|
|
name: By genre
|
|
groupBy:
|
|
property: note.genre
|
|
direction: ASC
|
|
";
|
|
let mut h = tab("png", text);
|
|
h.run();
|
|
h.get_by_label("Properties").click();
|
|
h.run();
|
|
h.run();
|
|
save(&mut h, &dir, "properties.png");
|
|
h.get_by_label("Properties").click();
|
|
h.run();
|
|
h.get_by_label("Filter").click();
|
|
h.run();
|
|
h.get_by_label("Add filter").click();
|
|
h.run_steps(6);
|
|
save(&mut h, &dir, "filter.png");
|
|
h.get_by_label("Filter").click();
|
|
h.run();
|
|
h.get_by_label("Sort").click();
|
|
h.run();
|
|
save(&mut h, &dir, "sort.png");
|
|
h.get_by_label("Sort").click();
|
|
h.run();
|
|
h.state_mut().view.view = 1;
|
|
h.run();
|
|
h.run();
|
|
save(&mut h, &dir, "grouped.png");
|
|
}
|
|
|
|
fn save(h: &mut egui_kittest::Harness<'_, Tab>, dir: &str, name: &str) {
|
|
let image = h.render().expect("renders");
|
|
std::fs::create_dir_all(dir).expect("dir");
|
|
image.save(std::path::Path::new(dir).join(name)).expect("png");
|
|
}
|