297 lines
15 KiB
Rust
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");
|
|
}
|
|
}
|