Files
Marcos Paulo 77d538dd97
ci / ci (macos-latest) (push) Has been cancelled
ci / ci (ubuntu-latest) (push) Has been cancelled
ci / ci (windows-latest) (push) Has been cancelled
feat: Bases Cards, List, embeds, Map view, Maps settings and inline cell editing
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 04:45:32 -03:00

341 lines
14 KiB
Rust

//! The Map view of a base, driven headless over a fixture vault with a stub tile server behind the
//! `maps.*` commands: clusters at low zoom that split at high zoom, the marker popup, the
//! attribution in every frame, no tile request while Maps is off, the Offline chip with a failing
//! server, the notice before Maps is switched on, and the frame cost of 360 markers.
use std::io::Cursor;
use std::time::Instant;
use egui::{Event, PointerButton, Pos2, Vec2};
use egui_kittest::kittest::Queryable;
use notescraft_bases::map::Attribution;
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};
/// What the stub tile server and settings answer, and what they were asked.
struct Maps {
enabled: bool,
fail: bool,
tiles: Vec<(u64, u64, u64)>,
png: String,
}
struct Tab {
view: BaseView,
text: String,
tokens: Tokens,
session: Session,
registry: Registry<Session>,
maps: Maps,
warmed: bool,
opened: Vec<String>,
}
const NEAR: usize = 12;
/// The Eiffel Tower, latitude and longitude.
const EIFFEL: (f64, f64) = (48.858_37, 2.294_481);
/// Frames timed by the perf test.
const FRAMES: u32 = 60;
/// Characters of the base64 alphabet.
const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
const FAR: [(&str, f64, f64); 3] = [("Tokyo", 35.68, 139.69), ("Sydney", -33.87, 151.21), ("Lima", -12.04, -77.04)];
fn base(zoom: u32) -> String {
format!(
"filters:
and:
- file.inFolder(\"Places\")
views:
- type: map
name: Places
coordinates: coordinates
markerIcon: icon
markerColor: color
center: [48.85837, 2.294481]
zoom: {zoom}
order:
- file.name
- note.tags
"
)
}
fn note(root: &std::path::Path, name: &str, lat: f64, lng: f64, extra: &str) {
let body = format!("---\ncoordinates:\n - \"{lat}\"\n - \"{lng}\"\ntags:\n - places\n{extra}---\n{name}\n");
std::fs::write(root.join(format!("Places/{name}.md")), body).expect("note");
}
fn vault(name: &str, extra_markers: usize) -> std::path::PathBuf {
let root = std::env::temp_dir().join(format!("notescraft-base-map-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(root.join("Places")).expect("places folder");
note(&root, "Eiffel Tower", EIFFEL.0, EIFFEL.1, "icon: landmark\ncolor: red\n");
for i in 0..u32::try_from(NEAR - 1).expect("small") {
note(&root, &format!("Paris {i:02}"), 48.858 + 0.001 * f64::from(i + 1), 2.2945, "");
}
for (place, lat, lng) in FAR {
note(&root, place, lat, lng, "icon: utensils\ncolor: \"#3366cc\"\n");
}
std::fs::write(root.join("Places/Nowhere.md"), "---\ntags:\n - places\n---\nNo position\n").expect("note");
for i in 0..u32::try_from(extra_markers).expect("small") {
// A lattice about the Eiffel Tower with a pin every 50 points or so at zoom 13.
let (row, col) = (f64::from(i / 20), f64::from(i % 20));
note(&root, &format!("Spread {i:03}"), EIFFEL.0 + (row - 8.0) * 0.0058, EIFFEL.1 + (col - 10.0) * 0.0085, "icon: landmark\n");
}
root
}
fn png() -> String {
let mut bytes = Vec::new();
image::RgbaImage::from_pixel(256, 256, image::Rgba([200, 220, 240, 255])).write_to(&mut Cursor::new(&mut bytes), image::ImageFormat::Png).expect("png");
let mut out = String::new();
for chunk in bytes.chunks(3) {
let n = chunk.iter().enumerate().fold(0_u32, |acc, (i, b)| acc | u32::from(*b) << (16 - 8 * i));
for i in 0..4 {
if i <= chunk.len() {
out.push(char::from(ALPHABET[((n >> (18 - 6 * i)) & 63) as usize]));
} else {
out.push('=');
}
}
}
out
}
fn tab(name: &str, zoom: u32, enabled: bool, fail: bool, extra_markers: usize) -> egui_kittest::Harness<'static, Tab> {
let root = vault(name, extra_markers);
let session = Session::open(&root).expect("the vault opens");
let mut registry = Registry::new();
register_core(&mut registry).expect("commands register");
let state = Tab {
view: BaseView::new("Places.base"),
text: base(zoom),
tokens: Tokens::new(builtin("catppuccin-mocha").expect("theme")),
session,
registry,
maps: Maps { enabled, fail, tiles: Vec::new(), png: png() },
warmed: false,
opened: Vec::new(),
};
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());
notescraft_ui_egui::motion::set_enabled(ui.ctx(), false);
tab.warmed = true;
return;
}
let shown = base_view::show(ui, &tab.tokens, &mut tab.view, &mut tab.text, true);
tab.opened.extend(shown.open.map(|link| link.target));
let Tab { view, session, registry, maps, .. } = tab;
let ran = view.pump(&mut |id, params| match id {
"maps.status" => Ok(json!({ "enabled": maps.enabled, "use_tiles": true, "host": "tiles.stub.test", "cache_bytes": 0, "tiles": 0 })),
"maps.tile" => {
assert!(maps.enabled, "a tile was asked for while Maps is off");
let at = |name: &str| params.get(name).and_then(Value::as_u64).unwrap_or(u64::MAX);
maps.tiles.push((at("z"), at("x"), at("y")));
if maps.fail { Err("the tile server could not be reached".to_owned()) } else { Ok(json!({ "png_base64": maps.png })) }
}
"settings.set" => {
maps.enabled = true;
Ok(json!({ "changed": true }))
}
_ => registry.run(id, session, params).map_err(|error| error.to_string()),
});
if ran {
ui.ctx().request_repaint();
}
},
state,
)
}
fn settle(h: &mut egui_kittest::Harness<'static, Tab>) {
for _ in 0..12 {
h.run_steps(2);
}
}
fn click(h: &mut egui_kittest::Harness<'static, Tab>, at: Pos2) {
h.input_mut().events.push(Event::PointerMoved(at));
h.input_mut().events.push(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE });
h.step();
h.input_mut().events.push(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE });
h.step();
h.step();
}
#[test]
fn markers_cluster_at_low_zoom() {
let mut h = tab("low", 3, true, false, 0);
settle(&mut h);
let stats = h.state().view.map_stats();
assert_eq!(stats.pins, NEAR + FAR.len(), "every note with a position is a pin: {stats:?}");
assert_eq!(stats.skipped, 1, "the note without coordinates is counted, not placed");
assert_eq!(stats.clusters, 1 + FAR.len(), "the twelve Paris notes share one cluster");
assert_eq!(stats.singles, FAR.len(), "the far notes stay single");
h.get_by_label_contains("1 notes without coordinates");
}
#[test]
fn markers_split_at_high_zoom() {
let mut h = tab("high", 17, true, false, 0);
settle(&mut h);
let stats = h.state().view.map_stats();
assert_eq!(stats.clusters, NEAR + FAR.len(), "{stats:?}");
assert_eq!(stats.singles, stats.pins, "nothing is clustered any more");
}
#[test]
fn the_zoom_buttons_step_one_level() {
let mut h = tab("buttons", 5, true, false, 0);
settle(&mut h);
let before = h.state().view.map_stats().zoom;
h.get_by_label("Zoom in").click();
settle(&mut h);
assert!((h.state().view.map_stats().zoom - (before + 1.0)).abs() < 0.01, "{before} -> {}", h.state().view.map_stats().zoom);
h.get_by_label("Zoom out").click();
settle(&mut h);
assert!((h.state().view.map_stats().zoom - before).abs() < 0.01);
}
#[test]
fn a_marker_opens_a_popup_with_the_view_properties() {
let mut h = tab("popup", 17, true, false, 0);
settle(&mut h);
let middle = h.state().view.map_stats().center;
click(&mut h, middle);
settle(&mut h);
assert!(h.state().view.map_stats().popup, "the click selected the marker under it");
h.get_by_label("Eiffel Tower");
h.get_by_label("tags");
h.get_by_label("places");
h.get_by_label("Open note").click();
settle(&mut h);
assert_eq!(h.state().opened, ["Places/Eiffel Tower.md"]);
}
#[test]
fn the_attribution_is_drawn_in_every_frame() {
for (name, enabled, fail) in [("credit-on", true, false), ("credit-off", false, false), ("credit-fail", true, true)] {
let mut h = tab(name, 4, enabled, fail, 0);
for step in 0..30 {
h.step();
if step > 0 {
assert!(h.query_by_label(Attribution::OSM_ATTRIBUTION).is_some(), "{name}: no credit in frame {step}");
}
}
}
}
#[test]
fn maps_off_fetches_nothing_and_offers_to_enable() {
let mut h = tab("off", 4, false, false, 0);
settle(&mut h);
assert!(h.state().maps.tiles.is_empty(), "no tile call while Maps is off");
h.get_by_label("Maps is off");
h.get_by_label("Enable Maps").click();
settle(&mut h);
h.get_by_label("Enable Maps?");
h.get_by_label_contains("tiles.stub.test");
assert!(h.state().maps.tiles.is_empty(), "the notice comes before the first request");
h.get_by_label("Keep off").click();
settle(&mut h);
assert!(!h.state().maps.enabled && h.state().maps.tiles.is_empty());
h.get_by_label("Enable Maps").click();
settle(&mut h);
h.get_by_label("Enable").click();
settle(&mut h);
assert!(h.state().maps.enabled, "Enable turns Maps on");
assert!(!h.state().maps.tiles.is_empty(), "tiles are asked for once it is on");
}
#[test]
fn a_failing_server_shows_the_grid_and_the_offline_chip() {
let mut h = tab("offline", 4, true, true, 0);
settle(&mut h);
let stats = h.state().view.map_stats();
assert!(stats.offline && stats.textures == 0, "{stats:?}");
assert!(stats.pins > 0, "the markers still work offline");
h.get_by_label("Offline: tiles not loaded");
assert!(h.state().maps.tiles.iter().all(|(z, _, _)| *z == 4), "{:?}", h.state().maps.tiles);
}
#[test]
fn tiles_become_textures_within_the_bound() {
let mut h = tab("tiles", 4, true, false, 0);
settle(&mut h);
let stats = h.state().view.map_stats();
assert!(stats.textures > 0, "{stats:?}");
assert!(stats.entries <= 128, "{stats:?}");
assert!(!h.state().view.map_stats().offline);
assert!(h.query_by_label("Offline: tiles not loaded").is_none());
let tiles = &h.state().maps.tiles;
let mut unique = tiles.clone();
unique.sort_unstable();
unique.dedup();
assert_eq!(unique.len(), tiles.len(), "a tile is asked for once: {tiles:?}");
}
#[test]
#[ignore = "perf: run with --ignored in release or debug"]
fn three_hundred_sixty_markers_cost_under_six_milliseconds_a_frame() {
let mut h = tab("perf", 13, true, false, 360 - NEAR - FAR.len());
settle(&mut h);
let pins = h.state().view.map_stats().pins;
assert_eq!(pins, 360);
let stats = h.state().view.map_stats();
assert!(stats.singles > 200, "most pins are painted one by one: {stats:?}");
let start = Instant::now();
for _ in 0..FRAMES {
h.step();
}
let per_frame = start.elapsed() / FRAMES;
eprintln!("360 markers: {per_frame:?} a frame");
assert!(per_frame.as_secs_f64() * 1000.0 < 6.0, "{per_frame:?}");
}
/// Renders the grid fallback with clusters and the Offline chip, the popup and the settings card to
/// the folder named by `NOTESCRAFT_MAP_PNG`.
#[test]
#[ignore = "renders PNGs for a visual check: set NOTESCRAFT_MAP_PNG to a folder"]
fn renders_the_map_popup_and_settings_card() {
let Ok(out) = std::env::var("NOTESCRAFT_MAP_PNG") else { return };
let out = std::path::PathBuf::from(out);
let mut h = tab("png-grid", 5, true, true, 0);
settle(&mut h);
h.render().expect("the frame renders").save(out.join("map-grid-clusters.png")).expect("png");
let mut h = tab("png-popup", 17, true, true, 0);
settle(&mut h);
let middle = h.state().view.map_stats().center;
click(&mut h, middle);
settle(&mut h);
h.render().expect("the frame renders").save(out.join("map-popup.png")).expect("png");
let mut h = tab("png-off", 4, false, false, 0);
settle(&mut h);
h.render().expect("the frame renders").save(out.join("map-placeholder.png")).expect("png");
let tokens = Tokens::new(builtin("catppuccin-mocha").expect("theme"));
let mut settings = notescraft_engine::AppSettings::default();
settings.maps.enabled = true;
let mut view = notescraft_ui_egui::settings_view::SettingsView::default();
view.selected = notescraft_ui_egui::settings_view::SettingsCategory::CorePlugins;
view.maps = Some(notescraft_ui_egui::settings_view::maps_data::MapsStatus { cache_bytes: 34 * 1024 * 1024, tiles: 812 });
let mut warmed = false;
let mut page = egui_kittest::Harness::builder().with_size(Vec2::new(1100.0, 1100.0)).build_ui(move |ui| {
if !warmed {
notescraft_ui_egui::fonts::install(ui.ctx());
egui_extras::install_image_loaders(ui.ctx());
warmed = true;
return;
}
let _ = notescraft_ui_egui::settings_view::show_settings(ui, &tokens, &mut view, &settings, &[]);
});
page.run_steps(6);
page.render().expect("the frame renders").save(out.join("settings-maps-card.png")).expect("png");
}