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

297 lines
15 KiB
Rust

//! Integration tests of typed frontmatter editing (SPEC 6 item 20): editing
//! one key touches only that entry, unknown YAML round-trips byte exact, and
//! values render, infer and validate like the properties panel needs.
use notescraft_markdown::{PropType, PropValue, YamlValueText, infer, infer_list, infer_type, parse_types_json, remove_key, rename_key, set_key, value_raw};
/// `set_key` on pre-rendered text, for the tables below.
fn set(src: &str, key: &str, value: &str) -> Result<String, notescraft_markdown::Error> {
set_key(src, key, &YamlValueText::new(value))
}
/// Rows are `(source, key, value, expected)`.
#[test]
fn edits_change_only_that_entry() {
let rows = [
("---\ntitle: Old\nflag: true\n---\nbody\n", "title", "New", "---\ntitle: New\nflag: true\n---\nbody\n"),
("---\na: 1\nb: 2\nc: 3\n---\n", "b", "two", "---\na: 1\nb: two\nc: 3\n---\n"),
("---\na: 1\n---\nbody", "added", "7", "---\na: 1\nadded: 7\n---\nbody"),
("---\na: 1\n---", "added", "7", "---\na: 1\nadded: 7\n---"),
("---\ntags: [a, b]\n---\n", "tags", "[a, b, c]", "---\ntags: [a, b, c]\n---\n"),
("---\ntags:\n - a\n - b\n---\n", "tags", "[a, b, c]", "---\ntags: [a, b, c]\n---\n"),
("---\ntags: [a, b]\n---\n", "tags", "one", "---\ntags: one\n---\n"),
("---\nempty:\n---\n", "empty", "now", "---\nempty: now\n---\n"),
("---\nempty:\n---\n", "filled", "1", "---\nempty:\nfilled: 1\n---\n"),
("---\ndup: one\ndup: two\n---\n", "dup", "first", "---\ndup: first\ndup: two\n---\n"),
];
for (index, (src, key, value, expected)) in rows.iter().enumerate() {
let got = set(src, key, value).unwrap_or_else(|error| panic!("row {index}: {error}"));
assert_eq!(got, *expected, "row {index}: set `{key}` to {value:?}");
}
}
#[test]
fn set_creates_a_block_when_there_is_none() {
let rows = [
("body\n", "k", "v", "---\nk: v\n---\nbody\n"),
("", "k", "v", "---\nk: v\n---\n"),
("---\nunclosed: 1\n", "k", "v", "---\nk: v\n---\n---\nunclosed: 1\n"),
("body with --- inside\n", "k", "v", "---\nk: v\n---\nbody with --- inside\n"),
("body\r\n", "k", "v", "---\nk: v\n---\nbody\r\n"),
];
for (index, (src, key, value, expected)) in rows.iter().enumerate() {
let got = set(src, key, value).unwrap_or_else(|error| panic!("row {index}: {error}"));
assert_eq!(got, *expected, "row {index}");
}
}
#[test]
fn removals_drop_only_the_entry() {
let rows = [
("---\na: 1\nb: 2\nc: 3\n---\n", "a", "---\nb: 2\nc: 3\n---\n"),
("---\na: 1\nb: 2\nc: 3\n---\n", "c", "---\na: 1\nb: 2\n---\n"),
("---\na: 1\n---\n", "a", "---\n---\n"),
("---\na: 1\nlist:\n - x\n - y\nb: 2\n---\n", "list", "---\na: 1\nb: 2\n---\n"),
("---\na: 1\n---\n", "absent", "---\na: 1\n---\n"),
("no frontmatter here\n", "a", "no frontmatter here\n"),
("---\n---\n", "a", "---\n---\n"),
("---\ndup: one\ndup: two\n---\n", "dup", "---\ndup: two\n---\n"),
];
for (index, (src, key, expected)) in rows.iter().enumerate() {
let got = remove_key(src, key).unwrap_or_else(|error| panic!("row {index}: {error}"));
assert_eq!(got, *expected, "row {index}: remove `{key}`");
}
}
#[test]
fn renames_keep_the_value_and_everything_else() {
let rows = [
("---\na: 1\nb: 2\n---\nbody\n", "a", "first", "---\nfirst: 1\nb: 2\n---\nbody\n"),
("---\na: 1\nlist:\n - x\n - y\nb: 2\n---\n", "list", "items", "---\na: 1\nitems:\n - x\n - y\nb: 2\n---\n"),
("---\na: 'it''s'\n---\n", "a", "quoted", "---\nquoted: 'it''s'\n---\n"),
("---\n# keep\na: 1\n---\n", "a", "renamed", "---\n# keep\nrenamed: 1\n---\n"),
("---\na: 1\n---\n", "absent", "any", "---\na: 1\n---\n"),
("no frontmatter\n", "a", "any", "no frontmatter\n"),
("---\r\na: 1\r\n---\r\nbody\r\n", "a", "renamed", "---\r\nrenamed: 1\r\n---\r\nbody\r\n"),
("---\nklé: v\n---\n", "klé", "clé", "---\nclé: v\n---\n"),
];
for (index, (src, key, new_key, expected)) in rows.iter().enumerate() {
let got = rename_key(src, key, new_key).unwrap_or_else(|error| panic!("row {index}: {error}"));
assert_eq!(got, *expected, "row {index}: rename `{key}`");
}
}
#[test]
fn renames_of_unusable_names_are_refused() {
for name in ["", " ", " a", "- a", "# a", "[a]", "a:b", "a\nb"] {
assert!(matches!(rename_key("---\na: 1\n---\n", "a", name), Err(notescraft_markdown::Error::InvalidKey)), "name {name:?}");
}
}
#[test]
fn crlf_files_keep_their_endings() {
let rows = [
("---\r\ntitle: Old\r\n---\r\nbody\r\n", "title", "New", "---\r\ntitle: New\r\n---\r\nbody\r\n"),
("---\r\ntitle: Old\r\n---\r\nbody\r\n", "added", "New", "---\r\ntitle: Old\r\nadded: New\r\n---\r\nbody\r\n"),
("---\r\na: 1\r\nb: 2\r\n---\r\n", "a", "New", "---\r\na: New\r\nb: 2\r\n---\r\n"),
("---\r\na: 1\r\nlist:\r\n - x\r\n - y\r\n---\r\n", "list", "New", "---\r\na: 1\r\nlist: New\r\n---\r\n"),
];
for (index, (src, key, value, expected)) in rows.iter().enumerate() {
let got = set(src, key, value).unwrap_or_else(|error| panic!("row {index}: {error}"));
assert_eq!(got, *expected, "row {index}");
}
let removals = [
("---\r\na: 1\r\nb: 2\r\n---\r\n", "a", "---\r\nb: 2\r\n---\r\n"),
("---\r\na: 1\r\nlist:\r\n - x\r\n - y\r\n---\r\n", "list", "---\r\na: 1\r\n---\r\n"),
];
for (index, (src, key, expected)) in removals.iter().enumerate() {
let got = remove_key(src, key).unwrap_or_else(|error| panic!("row {index}: {error}"));
assert_eq!(got, *expected, "row {index}");
}
}
#[test]
fn comments_quotes_and_unicode_survive() {
let rows = [
("---\n# keep me\ntitle: Old\n---\n", "title", "New", "---\n# keep me\ntitle: New\n---\n"),
("---\ntitle: 'it''s'\n---\n", "flag", "true", "---\ntitle: 'it''s'\nflag: true\n---\n"),
("---\ntitle: \"a #b\"\n---\n", "flag", "1", "---\ntitle: \"a #b\"\nflag: 1\n---\n"),
("---\nklé: é\n---\n", "klé", "vé", "---\nklé: vé\n---\n"),
("---\nblock: |\n one\n two\na: 1\n---\n", "a", "2", "---\nblock: |\n one\n two\na: 2\n---\n"),
("---\nblock: |\n one\n two\na: 1\n---\n", "a", "", "---\nblock: |\n one\n two\na: \n---\n"),
("---\na: x \nb: y\n---\n", "b", "2", "---\na: x \nb: 2\n---\n"),
];
for (index, (src, key, value, expected)) in rows.iter().enumerate() {
let got = set(src, key, value).unwrap_or_else(|error| panic!("row {index}: {error}"));
assert_eq!(got, *expected, "row {index}");
}
}
#[test]
fn absurd_input_is_refused_not_written() {
let over_cap = "x".repeat(notescraft_markdown::MAX_ENTRY_BYTES + 1);
assert!(matches!(set("body\n", "k", over_cap.as_str()), Err(notescraft_markdown::Error::ValueTooLarge(_))));
let long_keys = vec![String::new(), "k\n".to_owned(), "k".repeat(notescraft_markdown::MAX_ENTRY_BYTES + 1)];
for key in &long_keys {
assert!(matches!(set("---\na: 1\n---\n", key, "v"), Err(notescraft_markdown::Error::InvalidKey)), "key {key:?}");
}
for value in ["a\nb", "a\r\nb", "a\n---\nb"] {
assert!(matches!(set("---\na: 1\n---\n", "k", value), Err(notescraft_markdown::Error::ValueTooLarge(_))), "value {value:?}");
}
assert!(set("no frontmatter\n", "ok key", "v").is_ok(), "a sane key creates the block");
}
#[test]
fn render_quotes_only_when_yaml_would_change_the_meaning() {
let rows: Vec<(PropValue, &str)> = vec![
(PropValue::Text(String::from("plain")), "plain"),
(PropValue::Text(String::from("é ok")), "é ok"),
(PropValue::Text(String::from("it's")), "it's"),
(PropValue::Text(String::from("a: b")), "'a: b'"),
(PropValue::Text(String::from("a # b")), "'a # b'"),
(PropValue::Text(String::from("# head")), "'# head'"),
(PropValue::Text(String::from("- no")), "'- no'"),
(PropValue::Text(String::from("trailing ")), "'trailing '"),
(PropValue::Text(String::from("true")), "'true'"),
(PropValue::Text(String::from("3")), "'3'"),
(PropValue::Text(String::new()), "''"),
(PropValue::Text(String::from("a: it's")), "'a: it''s'"),
(PropValue::Text(String::from("a\tb")), "\"a\\tb\""),
(PropValue::Number(1.5), "1.5"),
(PropValue::Number(3.0), "3.0"),
(PropValue::Bool(true), "true"),
(PropValue::Date(String::from("2026-01-02")), "2026-01-02"),
(PropValue::DateTime(String::from("2026-01-02T03:04:05")), "2026-01-02T03:04:05"),
(PropValue::List(vec![]), "[]"),
(PropValue::List(vec![String::from("a"), String::from("b, c")]), "[a, 'b, c']"),
];
for (index, (value, expected)) in rows.iter().enumerate() {
assert_eq!(value.render(), *expected, "row {index}");
}
}
#[test]
fn a_text_value_with_a_newline_is_escaped_in_double_quotes() {
let value = PropValue::Text(String::from("a\nb"));
assert_eq!(value.render(), "\"a\\nb\"");
assert!(set("---\na: 1\n---\n", "k", value.render().as_str()).is_ok());
}
#[test]
fn infer_reads_the_scalar_subset() {
let rows: Vec<(&str, PropValue)> = vec![
("plain", PropValue::Text(String::from("plain"))),
("\"in quotes\"", PropValue::Text(String::from("in quotes"))),
("'it''s'", PropValue::Text(String::from("it's"))),
("true", PropValue::Bool(true)),
("-3", PropValue::Number(-3.0)),
("1.5e3", PropValue::Number(1500.0)),
("2026-01-02", PropValue::Date(String::from("2026-01-02"))),
("2026-01-02T03:04:05Z", PropValue::DateTime(String::from("2026-01-02T03:04:05Z"))),
("10:30", PropValue::Text(String::from("10:30"))),
("a: b", PropValue::Text(String::from("a: b"))),
("[a, b]", PropValue::List(vec![String::from("a"), String::from("b")])),
("[]", PropValue::List(vec![])),
("|", PropValue::Text(String::from("|"))),
];
for (index, (raw, expected)) in rows.iter().enumerate() {
assert_eq!(infer(raw), *expected, "row {index}: infer({raw:?})");
}
}
#[test]
fn infer_list_reads_inline_and_block_forms() {
let rows: Vec<(&str, PropValue)> = vec![
("[a, \"c, d\"]", PropValue::List(vec![String::from("a"), String::from("c, d")])),
("- one\n- two", PropValue::List(vec![String::from("one"), String::from("two")])),
("[1, 2]", PropValue::List(vec![String::from("1"), String::from("2")])),
("solo", PropValue::List(vec![String::from("solo")])),
("", PropValue::List(vec![])),
("just text, not a list", PropValue::List(vec![String::from("just text, not a list")])),
];
for (index, (raw, expected)) in rows.iter().enumerate() {
assert_eq!(infer_list(raw), *expected, "row {index}: infer_list({raw:?})");
}
}
#[test]
fn infer_type_classifies_by_name_then_shape() {
let rows: Vec<(&str, PropValue, PropType)> = vec![
("tags", PropValue::Text(String::from("work")), PropType::Tags),
("tag", PropValue::List(vec![String::from("work")]), PropType::Tags),
("aliases", PropValue::Text(String::from("AKA")), PropType::Aliases),
("deadline", PropValue::Date(String::from("2026-01-02")), PropType::Date),
("deadline", PropValue::Text(String::from("2026-01-02")), PropType::Date),
("time", PropValue::Text(String::from("2026-01-02T03:04:05")), PropType::DateTime),
("done", PropValue::Bool(true), PropType::Checkbox),
("count", PropValue::Number(2.0), PropType::Number),
("authors", PropValue::List(vec![String::from("x")]), PropType::List),
("title", PropValue::Text(String::from("hello")), PropType::Text),
("deadline", PropValue::Text(String::from("junk")), PropType::Text),
(" deadline ", PropValue::Date(String::from("2026-01-02")), PropType::Date),
];
for (index, (key, value, expected)) in rows.iter().enumerate() {
assert_eq!(infer_type(key, value), *expected, "row {index}");
}
assert_eq!(PropType::Aliases.as_str(), "aliases");
assert_eq!(PropType::from_types_json("multitext"), Some(PropType::List));
assert_eq!(PropType::from_types_json("banana"), None);
}
#[test]
fn value_raw_reports_the_text_after_the_colon() {
let rows = [
("---\ntitle: Old\n---\n", "title", Some("Old")),
("---\ntitle: \"a: b\"\n---\n", "title", Some("\"a: b\"")),
("---\ntags:\n - a\n - b\n---\n", "tags", Some("- a\n- b")),
("---\nempty:\n---\n", "empty", Some("")),
("---\nabsent: 1\n---\n", "title", None),
("no frontmatter\n", "title", None),
];
for (index, (src, key, expected)) in rows.iter().enumerate() {
assert_eq!(value_raw(src, key).as_deref(), *expected, "row {index}");
}
}
#[test]
fn types_json_is_imported_and_junk_is_ignored() {
let good = r#"{"mappings": {}, "types": {"title": "text", "count": "number", "done": "checkbox", "deadline": "date", "at": "datetime", "extra": "multitext", "tags": "tags", "aka": "aliases"}}"#;
let types = parse_types_json(good);
assert_eq!(types.get("title"), Some(&PropType::Text));
assert_eq!(types.get("count"), Some(&PropType::Number));
assert_eq!(types.get("done"), Some(&PropType::Checkbox));
assert_eq!(types.get("deadline"), Some(&PropType::Date));
assert_eq!(types.get("at"), Some(&PropType::DateTime));
assert_eq!(types.get("extra"), Some(&PropType::List));
assert_eq!(types.get("tags"), Some(&PropType::Tags));
assert_eq!(types.get("aka"), Some(&PropType::Aliases));
for junk in ["", "{", "null", "[]", r#"{"types": {}}"#, r#"{"types": {"a": "banana"}}"#, r#"{"types": }"#, r#"{"a": {} }"#] {
assert!(parse_types_json(junk).is_empty(), "junk {junk:?} imports as no types");
}
let partial = r#"{"types": {"a": "text", "b": "banana", "c": "number"}}"#;
let partial = parse_types_json(partial);
assert_eq!(partial.len(), 2, "unknown types are skipped, known ones kept");
assert!(!partial.contains_key("b"));
}
/// A property set on a fresh key and removed again must return every byte.
#[test]
fn set_then_remove_round_trips_five_hundred_documents() {
let fms = [
"---\nempty:\n---\n",
"---\n---\n",
"---\ntitle: Old\n---\n",
"---\r\ntitle: Old\r\nflag: true\r\n---\r\n",
"---\n# keep\ntitle: \"A: B\"\nlist:\n - a\n - b\nweird: a: b\nk: 'q'\n---\n",
];
let bodies = ["", "one\n", "line one\nline two\n", "# Head\n#tag body\n", "héllo ünïcode\n---\nnot the close\n"];
for index in 0..500 {
let src = format!("{}{}", fms[index % fms.len()], bodies[(index / fms.len()) % bodies.len()]);
let set = set(src.as_str(), "prop", format!("v{index} v{index}").as_str()).unwrap_or_else(|error| panic!("doc {index}: {error}"));
assert_ne!(set, src, "doc {index}: the set added the entry");
assert!(set.contains(&format!("prop: v{index} v{index}")), "doc {index}: {set}");
let removed = remove_key(set.as_str(), "prop").unwrap_or_else(|error| panic!("doc {index}: {error}"));
assert_eq!(removed, src, "doc {index}: bytes moved");
}
}