249 lines
14 KiB
Rust
249 lines
14 KiB
Rust
//! Integration tests of the property commands (SPEC 6 item 20): one key per
|
|
//! edit, values validated against their type, buffer-aware writes and the
|
|
//! read-only refusal.
|
|
|
|
mod common;
|
|
|
|
use common::{on_disk, seed, temp_vault};
|
|
use notescraft_engine::{Error, Registry, Session, register_core};
|
|
use serde_json::{Value, json};
|
|
|
|
fn registry() -> Registry<Session> {
|
|
let mut reg = Registry::new();
|
|
register_core(&mut reg).expect("registry");
|
|
reg
|
|
}
|
|
|
|
fn properties_of(reg: &Registry<Session>, session: &mut Session, path: &str) -> Value {
|
|
reg.run("note.properties", session, &json!({ "path": path })).unwrap_or_else(|error| panic!("properties of {path}: {error}"))
|
|
}
|
|
|
|
/// The `properties` array of a report, empty when it is not there.
|
|
fn rows(report: &Value) -> &[Value] {
|
|
match report.get("properties").and_then(Value::as_array) {
|
|
Some(rows) => rows,
|
|
None => &[],
|
|
}
|
|
}
|
|
|
|
fn property_of(reg: &Registry<Session>, session: &mut Session, path: &str, key: &str) -> Value {
|
|
let report = properties_of(reg, session, path);
|
|
let found = rows(&report).iter().find(|row| row.get("key").and_then(Value::as_str) == Some(key));
|
|
found.cloned().unwrap_or(Value::Null)
|
|
}
|
|
|
|
/// The code of a command failure, when `outcome` is one.
|
|
fn failure_code(outcome: &Result<Value, Error>) -> Option<String> {
|
|
match outcome {
|
|
Err(Error::Command { source, .. }) => Some(source.code.clone()),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// Rows appear in file order, `raw` keeps the text after the colon, and the
|
|
/// type comes from `.obsidian/types.json` or is inferred from the value.
|
|
#[test]
|
|
fn note_properties_lists_rows_in_file_order() {
|
|
let vault = temp_vault("properties-list");
|
|
seed(
|
|
vault.path(),
|
|
&[
|
|
("a.md", "---\ntitle: A title\ndone: true\ncount: -2\nat: 2026-01-02T03:04:05Z\ntags:\n - x\n - y\nweird: a: b\n---\nbody\n"),
|
|
("b.md", "no frontmatter\n"),
|
|
],
|
|
);
|
|
let mut session = Session::open(vault.path()).expect("session");
|
|
let reg = registry();
|
|
|
|
let report = properties_of(®, &mut session, "a.md");
|
|
assert_eq!(report.get("has_frontmatter").and_then(Value::as_bool), Some(true), "{report}");
|
|
let keys: Vec<&str> = rows(&report).iter().filter_map(|row| row.get("key").and_then(Value::as_str)).collect();
|
|
assert_eq!(keys, vec!["title", "done", "count", "at", "tags", "weird"], "{report}");
|
|
let title = property_of(®, &mut session, "a.md", "title");
|
|
assert_eq!(title.get("name").and_then(Value::as_str), Some("text"), "{}", title.get("name").and_then(Value::as_str).unwrap_or("-"));
|
|
assert_eq!(title.get("value").and_then(Value::as_str), Some("A title"));
|
|
assert_eq!(title.get("raw").and_then(Value::as_str), Some("A title"));
|
|
assert_eq!(property_of(®, &mut session, "a.md", "done").get("name").and_then(Value::as_str), Some("checkbox"));
|
|
assert_eq!(property_of(®, &mut session, "a.md", "count").get("value").and_then(Value::as_f64), Some(-2.0));
|
|
assert_eq!(property_of(®, &mut session, "a.md", "at").get("name").and_then(Value::as_str), Some("datetime"));
|
|
let tags = property_of(®, &mut session, "a.md", "tags");
|
|
assert_eq!(tags.get("name").and_then(Value::as_str), Some("tags"));
|
|
assert_eq!(tags.get("raw").and_then(Value::as_str), Some("- x\n- y"));
|
|
assert_eq!(property_of(®, &mut session, "a.md", "weird").get("raw").and_then(Value::as_str), Some("a: b"));
|
|
|
|
let empty = properties_of(®, &mut session, "b.md");
|
|
assert_eq!(empty.get("has_frontmatter").and_then(Value::as_bool), Some(false), "{empty}");
|
|
assert_eq!(rows(&empty).len(), 0);
|
|
assert!(reg.run("note.properties", &mut session, &json!({ "path": "no such.md" })).is_err());
|
|
}
|
|
|
|
/// An explicit `type` beats `.obsidian/types.json`, which beats inference;
|
|
/// a type from Obsidian is imported read-only and survives a restart.
|
|
#[test]
|
|
fn types_json_import_overrides_the_inference() {
|
|
let vault = temp_vault("properties-types");
|
|
seed(
|
|
vault.path(),
|
|
&[
|
|
("a.md", "---\ndone: yes\nscore: 7\n---\n"),
|
|
(".obsidian/types.json", r#"{"types": {"score": "number", "done": "checkbox", "extra": "multitext"}, "junk": 1}"#),
|
|
],
|
|
);
|
|
let mut session = Session::open(vault.path()).expect("session");
|
|
let reg = registry();
|
|
assert_eq!(property_of(®, &mut session, "a.md", "done").get("name").and_then(Value::as_str), Some("checkbox"));
|
|
assert_eq!(property_of(®, &mut session, "a.md", "score").get("name").and_then(Value::as_str), Some("number"));
|
|
|
|
// The imported type also applies to a value set through the command.
|
|
let set = reg.run("note.property_set", &mut session, &json!({ "path": "a.md", "key": "score", "value": "9" }));
|
|
assert!(set.is_ok(), "{set:?}");
|
|
let score = property_of(®, &mut session, "a.md", "score");
|
|
assert_eq!(score.get("value").and_then(Value::as_f64), Some(9.0));
|
|
assert!(on_disk(vault.path(), "a.md").contains("score: 9"), "an unquoted number was written");
|
|
}
|
|
|
|
/// A scalar edit rewrites one line; a list is an array of strings; a plain
|
|
/// string for a tags property becomes a single item.
|
|
#[test]
|
|
fn property_set_rewrites_one_entry() {
|
|
let vault = temp_vault("properties-set");
|
|
seed(vault.path(), &[("a.md", "---\ntitle: Old\n# keep\ntags:\n - x\n - y\n---\nbody [[Link]]\n")]);
|
|
let mut session = Session::open(vault.path()).expect("session");
|
|
let reg = registry();
|
|
|
|
let set = reg.run("note.property_set", &mut session, &json!({ "path": " a.md ", "key": "title", "value": "New, é" }));
|
|
assert!(set.is_ok(), "{set:?}");
|
|
assert_eq!(on_disk(vault.path(), "a.md"), "---\ntitle: New, é\n# keep\ntags:\n - x\n - y\n---\nbody [[Link]]\n");
|
|
assert!(session.index().tags().roots().iter().any(|node| node.tag == "x"), "the index was refreshed");
|
|
|
|
let listed = reg.run("note.property_set", &mut session, &json!({ "path": "a.md", "key": "tags", "value": ["x", "z"] }));
|
|
assert!(listed.is_ok(), "{listed:?}");
|
|
assert_eq!(on_disk(vault.path(), "a.md"), "---\ntitle: New, é\n# keep\ntags: [x, z]\n---\nbody [[Link]]\n");
|
|
|
|
let single = reg.run("note.property_set", &mut session, &json!({ "path": "a.md", "key": "tags", "type": "tags", "value": "solo" }));
|
|
assert!(single.is_ok(), "{single:?}");
|
|
assert!(on_disk(vault.path(), "a.md").contains("tags: [solo]"), "a single tags string becomes a one-item list");
|
|
|
|
// Setting a note without frontmatter creates the block at byte 0.
|
|
seed(vault.path(), &[("b.md", "body\n")]);
|
|
let created = reg.run("note.property_set", &mut session, &json!({ "path": "b.md", "key": "k", "value": "v" }));
|
|
assert!(created.is_ok(), "{created:?}");
|
|
assert_eq!(on_disk(vault.path(), "b.md"), "---\nk: v\n---\nbody\n");
|
|
|
|
// Writing the same value again changes nothing.
|
|
let same = reg.run("note.property_set", &mut session, &json!({ "path": "b.md", "key": "k", "value": "v" }));
|
|
let changed = same.as_ref().ok().and_then(|value| value.get("changed").and_then(Value::as_bool));
|
|
assert_eq!(changed, Some(false), "{same:?}");
|
|
}
|
|
|
|
/// Every kind of invalid value is refused with `invalid_value` (or
|
|
/// `invalid_params` for an unknown type) and nothing is written.
|
|
#[test]
|
|
fn property_set_validates_values() {
|
|
let vault = temp_vault("properties-validate");
|
|
seed(vault.path(), &[("a.md", "---\nnum: 1\ndate: 2026-01-02\ndone: false\nlist: [a]\n---\nbody\n")]);
|
|
let mut session = Session::open(vault.path()).expect("session");
|
|
let reg = registry();
|
|
let before = on_disk(vault.path(), "a.md");
|
|
let bad: Vec<Value> = vec![
|
|
json!({ "path": "a.md", "key": "num", "type": "number", "value": "9x" }),
|
|
json!({ "path": "a.md", "key": "num", "type": "number", "value": true }),
|
|
json!({ "path": "a.md", "key": "date", "type": "date", "value": "2026-02-30" }),
|
|
json!({ "path": "a.md", "key": "date", "type": "date", "value": "junk" }),
|
|
json!({ "path": "a.md", "key": "at", "type": "datetime", "value": "2026-01-02" }),
|
|
json!({ "path": "a.md", "key": "done", "type": "checkbox", "value": "yes" }),
|
|
json!({ "path": "a.md", "key": "list", "type": "list", "value": [1] }),
|
|
json!({ "path": "a.md", "key": "list", "type": "list", "value": 3 }),
|
|
json!({ "path": "a.md", "key": "k", "type": "banana", "value": "v" }),
|
|
json!({ "path": "a.md", "key": "k", "value": null }),
|
|
json!({ "path": "a.md", "key": "k\n", "value": "v" }),
|
|
];
|
|
for (index, params) in bad.iter().enumerate() {
|
|
let outcome = reg.run("note.property_set", &mut session, params);
|
|
let Err(Error::Command { source: failure, .. }) = outcome else { panic!("row {index} of {params} should fail: {outcome:?}") };
|
|
assert!(failure.code == "invalid_value" || failure.code == "invalid_params", "row {index}: {failure}");
|
|
assert_eq!(on_disk(vault.path(), "a.md"), before, "row {index}: nothing was written");
|
|
}
|
|
let long = "x".repeat(notescraft_markdown::MAX_ENTRY_BYTES + 1);
|
|
assert!(reg.run("note.property_set", &mut session, &json!({ "path": "a.md", "key": "big", "value": long })).is_err());
|
|
assert!(!on_disk(vault.path(), "a.md").contains("big"), "the oversized value was refused");
|
|
}
|
|
|
|
/// Remove and rename touch only their entry; an absent key is `changed: false`,
|
|
/// an unusable new name is refused.
|
|
#[test]
|
|
fn property_remove_and_rename_edit_one_entry() {
|
|
let vault = temp_vault("properties-remove-rename");
|
|
seed(vault.path(), &[("a.md", "---\na: 1\nlist:\n - x\n - y\nb: 2\n# kept\n---\nbody\n")]);
|
|
let mut session = Session::open(vault.path()).expect("session");
|
|
let reg = registry();
|
|
|
|
let removed = reg.run("note.property_remove", &mut session, &json!({ "path": "a.md", "key": "list" }));
|
|
assert!(removed.is_ok(), "{removed:?}");
|
|
assert_eq!(on_disk(vault.path(), "a.md"), "---\na: 1\nb: 2\n# kept\n---\nbody\n");
|
|
|
|
let renamed = reg.run("note.property_rename", &mut session, &json!({ "path": "a.md", "from": "a", "to": "first" }));
|
|
assert!(renamed.is_ok(), "{renamed:?}");
|
|
assert_eq!(on_disk(vault.path(), "a.md"), "---\nfirst: 1\nb: 2\n# kept\n---\nbody\n");
|
|
|
|
let removed = reg.run("note.property_remove", &mut session, &json!({ "path": "a.md", "key": "absent" }));
|
|
let changed = removed.as_ref().ok().and_then(|value| value.get("changed").and_then(Value::as_bool));
|
|
assert_eq!(changed, Some(false), "{removed:?}");
|
|
let renamed = reg.run("note.property_rename", &mut session, &json!({ "path": "a.md", "from": "b", "to": "b" }));
|
|
let changed = renamed.as_ref().ok().and_then(|value| value.get("changed").and_then(Value::as_bool));
|
|
assert_eq!(changed, Some(false), "{renamed:?}");
|
|
let bad = reg.run("note.property_rename", &mut session, &json!({ "path": "a.md", "from": "b", "to": "-no" }));
|
|
assert_eq!(failure_code(&bad), Some(String::from("invalid_value")), "{bad:?}");
|
|
assert_eq!(on_disk(vault.path(), "a.md"), "---\nfirst: 1\nb: 2\n# kept\n---\nbody\n");
|
|
|
|
let missing = reg.run("note.property_remove", &mut session, &json!({ "path": "gone.md", "key": "a" }));
|
|
assert_eq!(failure_code(&missing), Some(String::from("not_found")), "{missing:?}");
|
|
}
|
|
|
|
/// With a buffer open the edit becomes one undoable buffer edit left dirty;
|
|
/// the disk only changes when the buffer is saved.
|
|
#[test]
|
|
fn a_property_edit_goes_through_an_open_buffer() {
|
|
let vault = temp_vault("properties-buffer");
|
|
seed(vault.path(), &[("a.md", "---\ntitle: Old\n---\nbody #tag\n")]);
|
|
let mut session = Session::open(vault.path()).expect("session");
|
|
session.open_buffer("a.md").expect("buffer");
|
|
session.buffer_mut("a.md").expect("buffer").edit(on_disk(vault.path(), "a.md").len()..on_disk(vault.path(), "a.md").len(), " tail").expect("buffer edit");
|
|
let reg = registry();
|
|
|
|
let set = reg.run("note.property_set", &mut session, &json!({ "path": "a.md", "key": "title", "value": "New" }));
|
|
assert!(set.is_ok(), "{set:?}");
|
|
let buffer = session.buffer("a.md").expect("open buffer");
|
|
assert!(buffer.text().contains("title: New"), "{}", buffer.text());
|
|
assert!(buffer.is_dirty(), "the buffer edit marks the note dirty");
|
|
assert_eq!(on_disk(vault.path(), "a.md"), "---\ntitle: Old\n---\nbody #tag\n", "the disk still holds what was saved last");
|
|
assert!(buffer.can_undo(), "one undo step covers the property edit");
|
|
let edit_area = session.buffer_mut("a.md").expect("buffer");
|
|
edit_area.undo().expect("undo");
|
|
assert!(edit_area.text().contains("title: Old"), "undo restores the entry: {}", edit_area.text());
|
|
assert!(edit_area.text().ends_with(" tail"), "undo keeps the editor's own edit");
|
|
|
|
let saved = reg.run("buffer.save", &mut session, &json!({ "path": "a.md" }));
|
|
assert!(saved.is_ok(), "{saved:?}");
|
|
let after_undo = reg.run("note.property_set", &mut session, &json!({ "path": "a.md", "key": "title", "value": "New" }));
|
|
assert!(after_undo.is_ok(), "{after_undo:?}");
|
|
}
|
|
|
|
/// A read-only session refuses the three writing commands; reads stay fine.
|
|
#[test]
|
|
fn a_read_only_session_refuses_the_property_writes() {
|
|
let vault = temp_vault("properties-readonly");
|
|
seed(vault.path(), &[("a.md", "---\ntitle: Old\n---\nbody\n")]);
|
|
let mut session = Session::open_read_only(vault.path()).expect("read-only session");
|
|
let reg = registry();
|
|
for (id, params) in [
|
|
("note.property_set", json!({ "path": "a.md", "key": "title", "value": "New" })),
|
|
("note.property_remove", json!({ "path": "a.md", "key": "title" })),
|
|
("note.property_rename", json!({ "path": "a.md", "from": "title", "to": "name" })),
|
|
] {
|
|
assert!(matches!(reg.run(id, &mut session, ¶ms), Err(Error::Disabled(_))), "{id} must be refused");
|
|
}
|
|
assert!(reg.run("note.properties", &mut session, &json!({ "path": "a.md" })).is_ok(), "reading still works");
|
|
assert_eq!(on_disk(vault.path(), "a.md"), "---\ntitle: Old\n---\nbody\n");
|
|
}
|