Files
2026-10-14 23:16:33 -03:00

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(&reg, &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(&reg, &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(&reg, &mut session, "a.md", "done").get("name").and_then(Value::as_str), Some("checkbox"));
assert_eq!(property_of(&reg, &mut session, "a.md", "count").get("value").and_then(Value::as_f64), Some(-2.0));
assert_eq!(property_of(&reg, &mut session, "a.md", "at").get("name").and_then(Value::as_str), Some("datetime"));
let tags = property_of(&reg, &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(&reg, &mut session, "a.md", "weird").get("raw").and_then(Value::as_str), Some("a: b"));
let empty = properties_of(&reg, &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(&reg, &mut session, "a.md", "done").get("name").and_then(Value::as_str), Some("checkbox"));
assert_eq!(property_of(&reg, &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(&reg, &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, &params), 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");
}