Files
Marcos Paulo eb1245bd20
ci / ci (macos-latest) (push) Has been cancelled
ci / ci (ubuntu-latest) (push) Has been cancelled
ci / ci (windows-latest) (push) Has been cancelled
fix: tab switch freeze (unlinked scan off the UI thread); assistant: vault MCP wiring, preamble, wrapping choices, presets; 23 grammars incl. Twig; Tasks query, Templater include, icon picker
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 13:36:33 -03:00

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())));
}