197 lines
8.5 KiB
Rust
197 lines
8.5 KiB
Rust
//! The icon of a note, driven headless: a Lucide icon, an emoji and a picture of the
|
|
//! vault paint without a panic, a name outside the catalogue paints the generic file
|
|
//! icon, a big SVG is refused and the texture cache stays bounded.
|
|
|
|
use std::path::PathBuf;
|
|
|
|
use egui::Rect;
|
|
use notescraft_index::IconRef;
|
|
use notescraft_ui_egui::app::decor::DecorSnapshot;
|
|
use notescraft_ui_egui::design::icon_catalogue::{self, Catalogue};
|
|
use notescraft_ui_egui::design::note_icon;
|
|
use serde_json::json;
|
|
|
|
/// The state the harness keeps between the frames of one test.
|
|
struct Icons {
|
|
/// The vault the pictures of the icons live in.
|
|
root: PathBuf,
|
|
/// The pictures painted as file icons, one after the other.
|
|
targets: Vec<String>,
|
|
}
|
|
|
|
/// A vault with `files` in it, and the icons of a Lucide name, an emoji and every
|
|
/// `targets` painted in every frame.
|
|
fn harness(name: &str, files: &[(&str, &str)], targets: &[String]) -> egui_kittest::Harness<'static, Icons> {
|
|
let root = std::env::temp_dir().join(format!("notescraft-note-icon-{name}-{}", std::process::id()));
|
|
let _ = std::fs::remove_dir_all(&root);
|
|
std::fs::create_dir_all(&root).expect("the temp vault is made");
|
|
for (path, text) in files {
|
|
let file = root.join(path);
|
|
if let Some((folder, _)) = path.rsplit_once('/') {
|
|
std::fs::create_dir_all(root.join(folder)).expect("the folder is made");
|
|
}
|
|
std::fs::write(file, text).expect("the file is written");
|
|
}
|
|
egui_kittest::Harness::builder().with_size(egui::Vec2::new(240.0, 240.0)).build_ui_state(
|
|
|ui, state: &mut Icons| {
|
|
egui_extras::install_image_loaders(ui.ctx());
|
|
note_icon::set_vault_root(&state.root);
|
|
let rect = Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(64.0, 64.0));
|
|
note_icon::paint(ui, &IconRef::Lucide("rocket".to_owned()), rect, egui::Color32::WHITE);
|
|
note_icon::paint(ui, &IconRef::Emoji("🚀".to_owned()), rect, egui::Color32::WHITE);
|
|
for target in &state.targets {
|
|
note_icon::paint(ui, &IconRef::File(target.clone()), rect, egui::Color32::WHITE);
|
|
}
|
|
},
|
|
Icons { root, targets: targets.to_vec() },
|
|
)
|
|
}
|
|
|
|
/// Runs a few frames of the harness; the icons are painted in every one of them.
|
|
fn frames(harness: &mut egui_kittest::Harness<'static, Icons>) {
|
|
for _ in 0..5 {
|
|
harness.step();
|
|
}
|
|
}
|
|
|
|
/// Whether the shape paints a picture: a textured rectangle or a mesh.
|
|
fn is_image(shape: &egui::Shape) -> bool {
|
|
match shape {
|
|
egui::Shape::Rect(rect) => rect.brush.is_some(),
|
|
egui::Shape::Mesh(_) => true,
|
|
_ => false,
|
|
}
|
|
}
|
|
|
|
/// The textures of a frame, applied to the context so the next frame can paint them.
|
|
fn apply_textures(ctx: &egui::Context, output: &mut egui::FullOutput) {
|
|
let manager = ctx.tex_manager();
|
|
let mut manager = manager.write();
|
|
for (id, deltas) in output.textures_delta.set.drain() {
|
|
for delta in deltas {
|
|
manager.set(id, delta);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A tiny SVG, the shape of a file icon of the vault.
|
|
fn svg(body: &str) -> String {
|
|
format!("<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\">{body}</svg>")
|
|
}
|
|
|
|
/// The shapes one icon paints in a frame of its own.
|
|
fn painted(icon: &IconRef) -> Vec<egui::Shape> {
|
|
let ctx = egui::Context::default();
|
|
egui_extras::install_image_loaders(&ctx);
|
|
let rect = Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(64.0, 64.0));
|
|
let input = egui::RawInput { screen_rect: Some(Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(120.0, 120.0))), ..Default::default() };
|
|
let mut output = ctx.run_ui(input, |ui| {
|
|
note_icon::paint(ui, icon, rect, egui::Color32::WHITE);
|
|
});
|
|
output.textures_delta.clear();
|
|
output.shapes.into_iter().map(|shape| shape.shape).collect()
|
|
}
|
|
|
|
#[test]
|
|
fn the_emoji_is_painted_as_text() {
|
|
let shapes = painted(&IconRef::Emoji("🚀".to_owned()));
|
|
let texts: Vec<String> = shapes
|
|
.iter()
|
|
.filter_map(|shape| match shape {
|
|
egui::Shape::Text(text) => Some(text.galley.text().to_owned()),
|
|
_ => None,
|
|
})
|
|
.collect();
|
|
assert!(texts.iter().any(|text| text.contains('🚀')), "the emoji is drawn: {texts:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn a_lucide_icon_is_painted_as_an_image() {
|
|
let shapes = painted(&IconRef::Lucide("rocket".to_owned()));
|
|
let images = shapes.iter().filter(|shape| is_image(shape)).count();
|
|
assert!(images > 0, "the icon is drawn as an image");
|
|
}
|
|
|
|
#[test]
|
|
fn a_file_icon_is_decoded_off_the_ui_thread_and_painted() {
|
|
let root = std::env::temp_dir().join(format!("notescraft-note-icon-file-{}", std::process::id()));
|
|
let _ = std::fs::remove_dir_all(&root);
|
|
std::fs::create_dir_all(root.join("icons")).expect("the folder is made");
|
|
std::fs::write(root.join("icons/star.svg"), svg("<circle cx=\"12\" cy=\"12\" r=\"9\"/>")).expect("the file is written");
|
|
let ctx = egui::Context::default();
|
|
egui_extras::install_image_loaders(&ctx);
|
|
note_icon::set_vault_root(&root);
|
|
let rect = Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(64.0, 64.0));
|
|
let mut drawn = false;
|
|
// Poll up to ~10 s: the decode runs on a worker thread that the loaded full suite starves.
|
|
for _ in 0..1000 {
|
|
let input = egui::RawInput { screen_rect: Some(Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(120.0, 120.0))), ..Default::default() };
|
|
let mut output = ctx.run_ui(input, |ui| {
|
|
note_icon::paint(ui, &IconRef::File("icons/star.svg".to_owned()), rect, egui::Color32::WHITE);
|
|
});
|
|
apply_textures(&ctx, &mut output);
|
|
if output.shapes.iter().any(|shape| is_image(&shape.shape)) {
|
|
drawn = true;
|
|
break;
|
|
}
|
|
std::thread::sleep(std::time::Duration::from_millis(10));
|
|
}
|
|
assert!(drawn, "the picture of the vault is painted");
|
|
}
|
|
|
|
#[test]
|
|
fn a_lucide_an_emoji_and_a_file_paint_without_a_panic() {
|
|
let mut harness = harness("paint", &[("icons/star.svg", &svg("<circle cx=\"12\" cy=\"12\" r=\"9\"/>"))], &["icons/star.svg".to_owned()]);
|
|
frames(&mut harness);
|
|
}
|
|
|
|
#[test]
|
|
fn a_name_outside_the_catalogue_paints_the_generic_icon() {
|
|
assert!(Catalogue::get("rocket").is_some());
|
|
assert!(Catalogue::get("no-such-icon").is_none());
|
|
assert_eq!(icon_catalogue::painted_name("rocket"), "rocket");
|
|
assert_eq!(icon_catalogue::painted_name("no-such-icon"), icon_catalogue::FALLBACK);
|
|
assert!(Catalogue::get(icon_catalogue::FALLBACK).is_some(), "the generic icon is bundled");
|
|
}
|
|
|
|
#[test]
|
|
fn a_big_svg_is_refused() {
|
|
let big = svg(&"<path d=\"M0 0h1v1z\"/>".repeat(30_000));
|
|
assert!(big.len() > 64 * 1024, "the file is over the cap");
|
|
let mut harness =
|
|
harness("big", &[("icons/huge.svg", &big), ("icons/star.svg", &svg("<circle cx=\"12\" cy=\"12\" r=\"9\"/>"))], &["icons/star.svg".to_owned()]);
|
|
frames(&mut harness);
|
|
assert!(!note_icon::accepts("icons/huge.svg"), "a big SVG is not an icon");
|
|
assert!(note_icon::accepts("icons/star.svg"), "a small SVG is an icon");
|
|
assert!(!note_icon::accepts("../outside.svg"), "a path out of the vault is not an icon");
|
|
}
|
|
|
|
#[test]
|
|
fn the_texture_cache_never_holds_more_than_its_cap() {
|
|
let files: Vec<(String, String)> = (0..300).map(|number| (format!("icons/icon-{number}.svg"), svg("<circle cx=\"12\" cy=\"12\" r=\"9\"/>"))).collect();
|
|
let borrowed: Vec<(&str, &str)> = files.iter().map(|(path, text)| (path.as_str(), text.as_str())).collect();
|
|
let targets: Vec<String> = files.iter().map(|(path, _)| path.clone()).collect();
|
|
let mut harness = harness("cap", &borrowed, &targets);
|
|
frames(&mut harness);
|
|
assert!(note_icon::cached_file_icons() > 0, "the pictures of the vault are cached");
|
|
assert!(note_icon::cached_file_icons() <= note_icon::MAX_FILE_ICONS, "the cache is bounded");
|
|
}
|
|
|
|
#[test]
|
|
fn the_snapshot_moves_when_the_generation_moves() {
|
|
let mut snapshot = DecorSnapshot::default();
|
|
let answer = json!({
|
|
"items": { "notes/rocket.md": { "icon": { "kind": "lucide", "value": "rocket" } } },
|
|
"generation": 3
|
|
});
|
|
assert!(snapshot.apply(&answer));
|
|
assert_eq!(snapshot.get("notes/rocket.md").and_then(|item| item.icon.clone()), Some(IconRef::Lucide("rocket".to_owned())));
|
|
assert!(!snapshot.apply(&answer), "the same generation changes nothing");
|
|
let moved = json!({
|
|
"items": { "notes/rocket.md": { "icon": { "kind": "emoji", "value": "🚀" } } },
|
|
"generation": 4
|
|
});
|
|
assert!(snapshot.apply(&moved));
|
|
assert_eq!(snapshot.get("notes/rocket.md").and_then(|item| item.icon.clone()), Some(IconRef::Emoji("🚀".to_owned())));
|
|
}
|