Files
Marcos Paulo 86abc0c219
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: task status and toggle, Templater expression parser, decor.list/decor.set (MiniMax M3.1 Flash)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 08:12:19 -03:00

216 lines
10 KiB
Rust

//! 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>, 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>, session: &mut Session) -> serde_json::Map<String, Value> {
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(&reg, &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(&reg, &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(&reg, &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(&reg, &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(&reg, &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(&reg, &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,
}
}