//! Integration tests of the decoration commands: the icons, tints and banners //! of a vault, read from the index and written through the properties (D3). mod common; use common::{on_disk, seed, temp_vault}; use notescraft_engine::{DecorConfig, Error, Registry, Session, SessionConfig, register_core}; use serde_json::{Value, json}; /// A registry with the core commands and a session on a small decorated vault. fn fixture(name: &str) -> (Registry, Session, common::TempVault) { let vault = temp_vault(name); seed( vault.path(), &[ ("note.md", "---\nicon: LiRocket\niconColor: \"#ff8800\"\n---\n# Note\n\nThe body stays.\n"), ("plain.md", "# Plain\n"), ("custom.md", "---\ncover: rocket\n---\n# Custom\n"), ("banner.md", "---\nbanner: \"[[cover.jpg]]\"\nbanner_y: 0.2\n---\n# Banner\n"), ("remote.md", "---\nbanner: https://example.com/a.png\n---\n# Remote\n"), ("assets/cover.jpg", "not really a jpg"), ("Projects/Projects.md", "---\nicon: folder\n---\n# Projects\n"), ], ); let mut reg = Registry::new(); register_core(&mut reg).expect("register the core commands"); let session = Session::open(vault.path()).expect("open the vault"); (reg, session, vault) } /// The items of a `decor.list` answer. fn items(reg: &Registry, session: &mut Session) -> serde_json::Map { let result = reg.run("decor.list", session, &json!({})).expect("decor.list"); result.get("items").and_then(Value::as_object).expect("the items").clone() } /// The decorated note is listed, the plain one is not. #[test] fn the_decorated_note_is_listed_and_the_plain_one_is_not() { let (reg, mut session, _vault) = fixture("decor-list"); let items = items(®, &mut session); assert!(items.contains_key("note.md"), "{items:?}"); assert!(!items.contains_key("plain.md"), "{items:?}"); assert_eq!(items["note.md"]["icon"]["kind"], "lucide"); assert_eq!(items["note.md"]["icon"]["value"], "rocket"); assert_eq!(items["note.md"]["color"]["kind"], "rgb"); assert_eq!(items["note.md"]["color"]["value"], "#ff8800"); } /// The folder note decorates the folder itself. #[test] fn the_folder_note_decorates_the_folder() { let (reg, mut session, _vault) = fixture("decor-folder"); let items = items(®, &mut session); assert_eq!(items["Projects"]["icon"]["value"], "folder", "{items:?}"); } /// A banner link resolves like a wiki link. #[test] fn the_banner_link_resolves_to_the_file_it_names() { let (reg, mut session, _vault) = fixture("decor-banner"); let items = items(®, &mut session); assert_eq!(items["banner.md"]["banner"]["path"], "assets/cover.jpg", "{items:?}"); assert!(items["banner.md"]["banner"]["y"].as_f64().is_some_and(|y| (y - 0.2).abs() < 1e-6), "{items:?}"); assert_eq!(items["banner.md"]["banner"]["x"], 0.5); } /// A remote banner stays a URL: it is never a path and never fetched. #[test] fn a_remote_banner_is_a_url_and_not_a_path() { let (reg, mut session, _vault) = fixture("decor-remote"); let items = items(®, &mut session); let banner = &items["remote.md"]["banner"]; assert_eq!(banner["url"], "https://example.com/a.png", "{items:?}"); assert!(banner.get("path").is_none(), "{banner:?}"); } /// `decor.set` writes the property and leaves the body byte for byte. #[test] fn decor_set_writes_the_property_and_leaves_the_body_alone() { let (reg, mut session, vault) = fixture("decor-set"); let before = on_disk(vault.path(), "plain.md"); let result = reg.run("decor.set", &mut session, &json!({ "path": "plain.md", "icon": "rocket" })).expect("decor.set"); assert_eq!(result.get("path"), Some(&json!("plain.md"))); assert_eq!(result.get("changed"), Some(&json!(true))); let after = on_disk(vault.path(), "plain.md"); assert!(after.contains("icon: rocket"), "{after}"); assert!(after.ends_with("# Plain\n"), "the body moved: {after}"); assert_eq!(body_of(&after), body_of(&before)); } /// A null value removes the property again. #[test] fn a_null_value_removes_the_property() { let (reg, mut session, vault) = fixture("decor-remove"); reg.run("decor.set", &mut session, &json!({ "path": "note.md", "icon": null })).expect("decor.set"); let text = on_disk(vault.path(), "note.md"); assert!(!text.contains("icon:"), "{text}"); assert!(text.contains("iconColor"), "the other properties stay: {text}"); } /// An open buffer gets one undoable edit. #[test] fn an_open_buffer_gets_one_undo_step() { let (reg, mut session, _vault) = fixture("decor-undo"); reg.run("buffer.open", &mut session, &json!({ "path": "note.md" })).expect("buffer.open"); let before = session.buffer("note.md").map(|buffer| buffer.text().to_owned()).expect("the buffer is open"); reg.run("decor.set", &mut session, &json!({ "path": "note.md", "icon": "star" })).expect("decor.set"); let buffer = session.buffer("note.md").expect("the buffer is open"); assert!(buffer.text().contains("icon: star"), "{}", buffer.text()); let undone = reg.run("buffer.undo", &mut session, &json!({ "path": "note.md" })).expect("buffer.undo"); assert_eq!(undone.get("undone"), Some(&json!(true))); let buffer = session.buffer("note.md").expect("the buffer is open"); assert_eq!(buffer.text(), before, "the undo took the whole edit back"); assert!(!buffer.can_undo(), "one edit, one undo step"); } /// The property names are settings, so a vault may use another one. #[test] fn the_property_names_are_configurable() { let (reg, mut session, _vault) = fixture("decor-keys"); assert!(!items(®, &mut session).contains_key("custom.md"), "the default key does not match"); let config = SessionConfig { decor: DecorConfig { property_icon: String::from("cover"), ..DecorConfig::default() }, ..SessionConfig::default() }; session.set_config(config).expect("set the config"); let items = items(®, &mut session); assert_eq!(items["custom.md"]["icon"]["value"], "rocket", "{items:?}"); assert!(items["note.md"].get("icon").is_none(), "the icon key moved: {items:?}"); } /// A banner height outside the range is refused. #[test] fn an_out_of_range_banner_height_is_refused() { for height in [0, 79, 401] { let config = DecorConfig { banner_height: height, ..DecorConfig::default() }; assert!(config.validate().is_err(), "{height} must be refused"); } let config = DecorConfig::default(); assert!(config.validate().is_ok()); } /// A read-only session never writes a decoration. #[test] fn a_read_only_session_refuses_to_set() { let vault = temp_vault("decor-read-only"); seed(vault.path(), &[("a.md", "# A\n")]); let mut reg = Registry::new(); register_core(&mut reg).expect("register the core commands"); let mut session = Session::open_read_only(vault.path()).expect("open the vault read-only"); let result = reg.run("decor.set", &mut session, &json!({ "path": "a.md", "icon": "rocket" })); assert!(matches!(result, Err(Error::Disabled(ref id)) if id == "decor.set"), "{result:?}"); assert_eq!(on_disk(vault.path(), "a.md"), "# A\n"); } /// The decorations never reach the network. #[test] fn the_decor_commands_never_touch_the_network() { for source in [include_str!("../src/decor/resolve.rs"), include_str!("../src/decor/commands.rs"), include_str!("../src/decor/mod.rs")] { for needle in ["TcpStream", "ureq", "reqwest", "hyper", "std::net"] { assert!(!source.contains(needle), "`{needle}` must not appear in the decor commands"); } } } /// The generation moves when the decorations move, and stays when they do not. #[test] fn the_generation_follows_the_decorations() { let (reg, mut session, _vault) = fixture("decor-generation"); let first = reg.run("decor.list", &mut session, &json!({})).expect("decor.list"); let again = reg.run("decor.list", &mut session, &json!({})).expect("decor.list"); assert_eq!(first.get("generation"), again.get("generation"), "the same decorations give the same generation"); reg.run("decor.set", &mut session, &json!({ "path": "plain.md", "icon": "rocket" })).expect("decor.set"); let moved = reg.run("decor.list", &mut session, &json!({})).expect("decor.list"); assert_ne!(first.get("generation"), moved.get("generation"), "a new decoration gives a new generation"); } /// The property names travel through `settings.set` like every other key. #[test] fn the_property_names_round_trip_through_settings_set() { let vault = temp_vault("decor-settings"); seed(vault.path(), &[("custom.md", "---\ncover: rocket\n---\n# Custom\n")]); let config = common::temp_config("decor-settings-cfg"); let mut session = Session::open(vault.path()).expect("open the vault"); session.use_settings_dir(config.path()); let mut reg = Registry::new(); register_core(&mut reg).expect("register the core commands"); let set = reg.run("settings.set", &mut session, &json!({ "key": "session.decor.property_icon", "value": "cover" })).expect("settings.set"); assert!(set.get("key").is_some(), "{set:?}"); assert_eq!(session.config().decor.property_icon, "cover"); let result = reg.run("decor.list", &mut session, &json!({})).expect("decor.list"); assert!(result["items"].get("custom.md").is_some(), "{result}"); } /// The `paths` and `prefix` filters narrow the list. #[test] fn the_paths_and_prefix_filters_narrow_the_list() { let (reg, mut session, _vault) = fixture("decor-filter"); let only = reg.run("decor.list", &mut session, &json!({ "paths": ["note.md"] })).expect("decor.list"); let items = only["items"].as_object().expect("items"); assert_eq!(items.len(), 1, "{items:?}"); assert!(items.contains_key("note.md"), "{items:?}"); let under = reg.run("decor.list", &mut session, &json!({ "prefix": "Projects" })).expect("decor.list"); let items = under["items"].as_object().expect("items"); assert!(items.contains_key("Projects"), "the folder itself: {items:?}"); assert!(items.contains_key("Projects/Projects.md"), "its folder note: {items:?}"); let bad = reg.run("decor.list", &mut session, &json!({ "paths": [3] })).expect_err("a number is not a path"); assert!(bad.to_string().contains("paths"), "{bad}"); } /// The body of a note, without its frontmatter. fn body_of(text: &str) -> &str { match text.split_once("---\n") { Some((_, rest)) => rest.split_once("---\n").map_or(rest, |(_, body)| body), None => text, } }