341 lines
14 KiB
Rust
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");
|
|
}
|