use crate::document::edit::{is_valid_field_name, EditError};
use crate::document::meta::is_valid_kind_name;
use crate::document::{Card, Document};
use crate::value::QuillValue;
use crate::version::QuillReference;
use std::str::FromStr;
fn make_doc() -> Document {
Document::parse(
"~~~card-yaml\n$quill: test_quill\n$kind: main\ntitle: Hello\n~~~\n\nBody text.\n",
)
.unwrap()
.document
}
fn make_doc_with_cards() -> Document {
Document::parse(
"~~~card-yaml\n$quill: test_quill\n$kind: main\ntitle: Hello\n~~~\n\nBody.\n\n~~~card-yaml\n$kind: note\nfoo: bar\n~~~\n\nCard body.\n\n~~~card-yaml\n$kind: summary\n~~~\n",
)
.unwrap()
.document
}
fn qv(s: &str) -> QuillValue {
QuillValue::from_json(serde_json::json!(s))
}
fn commit_richtext(
card: &mut Card,
name: &str,
value: &serde_json::Value,
inline: bool,
) -> Result<(), EditError> {
use crate::quill::{FieldSchema, FieldType};
let schema = FieldSchema::new(name.to_string(), FieldType::RichText { inline }, None);
card.commit_field(name, QuillValue::from_json(value.clone()), &schema)
}
fn qv_int(n: i64) -> QuillValue {
QuillValue::from_json(serde_json::json!(n))
}
#[test]
fn test_valid_field_names() {
assert!(is_valid_field_name("title"));
assert!(is_valid_field_name("my_field"));
assert!(is_valid_field_name("_private"));
assert!(is_valid_field_name("abc123"));
assert!(is_valid_field_name("a1b2c3"));
assert!(is_valid_field_name("x"));
assert!(is_valid_field_name("_"));
assert!(is_valid_field_name("Title"));
assert!(is_valid_field_name("BODY"));
assert!(is_valid_field_name("MixedCase_1"));
}
#[test]
fn test_invalid_field_names() {
assert!(!is_valid_field_name(""));
assert!(!is_valid_field_name("123abc")); assert!(!is_valid_field_name("my-field")); assert!(!is_valid_field_name("my field")); assert!(!is_valid_field_name("$body")); }
#[test]
fn test_edit_error_display() {
assert!(EditError::InvalidFieldName("Bad-Name".to_string())
.to_string()
.contains("Bad-Name"));
assert!(EditError::InvalidKindName("Bad-Kind".to_string())
.to_string()
.contains("Bad-Kind"));
assert!(EditError::IndexOutOfRange { index: 3, len: 2 }
.to_string()
.contains("3"));
}
#[test]
fn test_document_store_field_rejects_dollar_prefixed_names() {
for name in ["$body", "$cards", "$quill", "$kind"] {
let mut doc = make_doc();
let result = doc.main_mut().store_field(name, qv("value"));
assert_eq!(
result,
Err(EditError::InvalidFieldName(name.to_string())),
"expected InvalidFieldName for '{}'",
name
);
}
}
#[test]
fn test_document_store_field_inserts() {
let mut doc = make_doc();
doc.main_mut().store_field("author", qv("Alice")).unwrap();
assert_eq!(
doc.main().payload().get("author").unwrap().as_str(),
Some("Alice")
);
}
#[test]
fn test_document_store_field_updates_existing() {
let mut doc = make_doc();
doc.main_mut().store_field("title", qv("New Title")).unwrap();
assert_eq!(
doc.main().payload().get("title").unwrap().as_str(),
Some("New Title")
);
}
#[test]
fn test_document_remove_field_existing() {
let mut doc = make_doc();
let removed = doc.main_mut().remove_field("title").unwrap();
assert_eq!(removed.unwrap().as_str(), Some("Hello"));
assert!(doc.main().payload().get("title").is_none());
}
#[test]
fn test_document_remove_field_absent() {
let mut doc = make_doc();
let removed = doc.main_mut().remove_field("nonexistent").unwrap();
assert!(removed.is_none());
}
#[test]
fn test_document_field_legacy_uppercase_accepted() {
let mut doc = make_doc();
for name in ["BODY", "CARDS", "QUILL", "CARD"] {
doc.main_mut()
.store_field(name, qv("v"))
.unwrap_or_else(|e| panic!("expected {name} to be accepted, got {e:?}"));
assert_eq!(
doc.main().payload().get(name).unwrap().as_str().unwrap(),
"v"
);
let removed = doc.main_mut().remove_field(name).unwrap();
assert_eq!(removed.unwrap().as_str().unwrap(), "v");
}
}
#[test]
fn test_document_remove_field_invalid_name_throws() {
let mut doc = make_doc();
match doc.main_mut().remove_field("Bad-Name") {
Err(EditError::InvalidFieldName(name)) => assert_eq!(name, "Bad-Name"),
other => panic!("expected InvalidFieldName, got {other:?}"),
}
}
#[test]
fn test_document_set_quill_ref() {
let mut doc = make_doc();
let new_ref = QuillReference::from_str("new_quill").unwrap();
doc.set_quill_ref(new_ref);
assert_eq!(doc.quill_reference().name, "new_quill");
}
#[test]
fn test_document_replace_body() {
let mut doc = make_doc();
doc.main_mut().revise_body("New body content.").unwrap();
assert_eq!(doc.main().body_markdown(), "New body content.");
}
#[test]
fn test_document_push_card() {
let mut doc = make_doc();
let card = Card::new("note").unwrap();
doc.push_card(card).unwrap();
assert_eq!(doc.cards().len(), 1);
assert_eq!(doc.cards()[0].kind(), Some("note"));
}
#[test]
fn test_document_insert_card_at_zero() {
let mut doc = make_doc_with_cards(); let card = Card::new("intro").unwrap();
doc.insert_card(0, card).unwrap();
assert_eq!(doc.cards().len(), 3);
assert_eq!(doc.cards()[0].kind(), Some("intro"));
assert_eq!(doc.cards()[1].kind(), Some("note"));
}
#[test]
fn test_document_insert_card_at_end() {
let mut doc = make_doc_with_cards(); let len = doc.cards().len();
let card = Card::new("footer").unwrap();
doc.insert_card(len, card).unwrap();
assert_eq!(doc.cards()[len].kind(), Some("footer"));
}
#[test]
fn test_document_insert_card_out_of_range() {
let mut doc = make_doc(); let card = Card::new("note").unwrap();
let result = doc.insert_card(1, card);
assert_eq!(result, Err(EditError::IndexOutOfRange { index: 1, len: 0 }));
}
#[test]
fn test_document_remove_card() {
let mut doc = make_doc_with_cards(); let removed = doc.remove_card(0);
assert!(removed.is_some());
assert_eq!(removed.unwrap().kind(), Some("note"));
assert_eq!(doc.cards().len(), 1);
assert_eq!(doc.cards()[0].kind(), Some("summary"));
}
#[test]
fn test_document_remove_card_out_of_range() {
let mut doc = make_doc();
let removed = doc.remove_card(0);
assert!(removed.is_none());
}
#[test]
fn test_document_card_mut() {
let mut doc = make_doc_with_cards();
{
let card = doc.card_mut(0).unwrap();
card.revise_body("Updated card body.").unwrap();
}
assert_eq!(doc.cards()[0].body_markdown(), "Updated card body.");
}
#[test]
fn test_document_card_mut_out_of_range() {
let mut doc = make_doc();
assert!(doc.card_mut(0).is_none());
}
#[test]
fn test_move_card_no_op_same_index() {
let mut doc = make_doc_with_cards(); let result = doc.move_card(0, 0);
assert_eq!(result, Ok(()));
assert_eq!(doc.cards()[0].kind(), Some("note"));
assert_eq!(doc.cards()[1].kind(), Some("summary"));
}
#[test]
fn test_move_card_last_to_first() {
let mut doc = make_doc_with_cards(); doc.move_card(1, 0).unwrap();
assert_eq!(doc.cards()[0].kind(), Some("summary"));
assert_eq!(doc.cards()[1].kind(), Some("note"));
}
#[test]
fn test_move_card_first_to_last() {
let mut doc = make_doc_with_cards(); let last = doc.cards().len() - 1;
doc.move_card(0, last).unwrap();
assert_eq!(doc.cards()[0].kind(), Some("summary"));
assert_eq!(doc.cards()[last].kind(), Some("note"));
}
#[test]
fn test_move_card_from_out_of_range() {
let mut doc = make_doc_with_cards(); let len = doc.cards().len();
let result = doc.move_card(len, 0);
assert_eq!(result, Err(EditError::IndexOutOfRange { index: len, len }));
}
#[test]
fn test_move_card_to_out_of_range() {
let mut doc = make_doc_with_cards(); let len = doc.cards().len();
let result = doc.move_card(0, len);
assert_eq!(result, Err(EditError::IndexOutOfRange { index: len, len }));
}
#[test]
fn test_set_card_kind_renames_in_place() {
let mut doc = make_doc_with_cards(); doc.set_card_kind(0, "annotation").unwrap();
assert_eq!(doc.cards()[0].kind(), Some("annotation"));
assert_eq!(
doc.cards()[0].payload().get("foo").unwrap().as_str(),
Some("bar")
);
assert_eq!(doc.cards()[1].kind(), Some("summary"));
}
#[test]
fn test_set_card_kind_rejects_invalid_kind() {
let mut doc = make_doc_with_cards();
for bad in ["", "Bad", "with-dash", "1leading_digit"] {
match doc.set_card_kind(0, bad) {
Err(EditError::InvalidKindName(t)) => assert_eq!(t, bad),
other => panic!("expected InvalidKindName for {bad:?}, got {other:?}"),
}
}
assert_eq!(doc.cards()[0].kind(), Some("note"));
}
#[test]
fn test_set_card_kind_index_out_of_range() {
let mut doc = make_doc_with_cards();
let len = doc.cards().len();
let result = doc.set_card_kind(len, "annotation");
assert_eq!(result, Err(EditError::IndexOutOfRange { index: len, len }));
}
#[test]
fn test_set_card_kind_round_trips_via_markdown() {
let mut doc = make_doc_with_cards();
doc.set_card_kind(0, "annotation").unwrap();
let md = doc.to_markdown();
let reparsed = crate::Document::parse(&md).unwrap().document;
assert_eq!(reparsed.cards()[0].kind(), Some("annotation"));
}
#[test]
fn test_card_new_invalid_kind_rejected() {
for kind in ["Note", "", "my-card"] {
assert_eq!(
Card::new(kind),
Err(EditError::InvalidKindName(kind.to_string()))
);
}
}
#[test]
fn test_card_store_field_valid() {
let mut card = Card::new("note").unwrap();
card.store_field("content", qv("Some text")).unwrap();
assert_eq!(
card.payload().get("content").unwrap().as_str(),
Some("Some text")
);
}
#[test]
fn test_card_store_field_invalid_name() {
let mut card = Card::new("note").unwrap();
let result = card.store_field("bad-name", qv("text"));
assert_eq!(
result,
Err(EditError::InvalidFieldName("bad-name".to_string()))
);
}
#[test]
fn test_document_new_blank_canvas() {
let mut doc = Document::new(QuillReference::from_str("test_quill").unwrap());
assert_eq!(doc.quill_reference().to_string(), "test_quill");
assert!(doc.cards().is_empty());
assert_eq!(doc.main().body_markdown(), "");
doc.main_mut().store_fields([("title", "Hello")]).unwrap();
let mut card = Card::new("note").unwrap();
card.store_field("qty", 3).unwrap();
doc.push_card(card).unwrap();
let reparsed = Document::parse(&doc.to_markdown()).unwrap().document;
assert_eq!(doc, reparsed);
}
#[test]
fn test_card_store_fields_inserts_in_iterator_order() {
let mut card = Card::new("note").unwrap();
card.store_fields([("b".to_string(), qv("two")), ("a".to_string(), qv("one"))])
.unwrap();
let keys: Vec<&String> = card.payload().iter().map(|(k, _)| k).collect();
assert_eq!(keys, ["b", "a"]);
assert_eq!(card.payload().get("a").unwrap().as_str(), Some("one"));
}
#[test]
fn test_card_store_fields_collects_every_violation() {
let mut card = Card::new("note").unwrap();
let errors = card
.store_fields([
("ok".to_string(), qv("fine")),
("bad-name".to_string(), qv("v")),
("also bad".to_string(), qv("v")),
])
.unwrap_err();
assert_eq!(errors.len(), 2);
assert_eq!(
errors[0],
(
"bad-name".to_string(),
EditError::InvalidFieldName("bad-name".to_string())
)
);
assert_eq!(
errors[1],
(
"also bad".to_string(),
EditError::InvalidFieldName("also bad".to_string())
)
);
}
#[test]
fn test_card_store_fields_atomic_on_error() {
let mut card = Card::new("note").unwrap();
card.store_field("existing", qv("old")).unwrap();
let result = card.store_fields([
("existing".to_string(), qv("new")),
("bad-name".to_string(), qv("v")),
]);
assert!(result.is_err());
assert_eq!(
card.payload().get("existing").unwrap().as_str(),
Some("old")
);
assert!(card.payload().get("bad-name").is_none());
}
#[test]
fn test_card_store_fields_clears_fill_and_repeated_name_last_wins() {
let mut card = Card::new("note").unwrap();
card.store_fill("title", qv("draft")).unwrap();
card.store_fields([
("title".to_string(), qv("first")),
("title".to_string(), qv("final")),
])
.unwrap();
let value = card.payload().get("title").unwrap();
assert!(!value.fill());
assert_eq!(value.as_str(), Some("final"));
}
#[test]
fn test_store_field_scalar_conversions() {
let mut card = Card::new("note").unwrap();
card.store_field("name", "Alice").unwrap();
card.store_field("qty", 3).unwrap();
card.store_field("price", 2.5).unwrap();
card.store_field("active", true).unwrap();
card.store_field("tags", serde_json::json!(["a", "b"]))
.unwrap();
card.store_fields([("count", 1), ("total", 2)]).unwrap();
assert_eq!(card.payload().get("name").unwrap().as_str(), Some("Alice"));
assert_eq!(card.payload().get("qty").unwrap().as_i64(), Some(3));
assert_eq!(card.payload().get("price").unwrap().as_f64(), Some(2.5));
assert_eq!(card.payload().get("active").unwrap().as_bool(), Some(true));
assert_eq!(card.payload().get("total").unwrap().as_i64(), Some(2));
}
#[test]
fn test_card_remove_field_existing() {
let mut doc = make_doc_with_cards();
let card = doc.card_mut(0).unwrap();
let removed = card.remove_field("foo").unwrap();
assert_eq!(removed.unwrap().as_str(), Some("bar"));
assert!(card.payload().get("foo").is_none());
}
#[test]
fn test_card_remove_field_absent() {
let mut card = Card::new("note").unwrap();
assert!(card.remove_field("nonexistent").unwrap().is_none());
}
#[test]
fn test_card_remove_field_invalid_name_throws() {
let mut card = Card::new("note").unwrap();
match card.remove_field("Bad-Name") {
Err(EditError::InvalidFieldName(name)) => assert_eq!(name, "Bad-Name"),
other => panic!("expected InvalidFieldName, got {other:?}"),
}
}
#[test]
fn test_card_set_body() {
let mut card = Card::new("note").unwrap();
card.revise_body("Card body text.").unwrap();
assert_eq!(card.body_markdown(), "Card body text.");
}
#[test]
fn test_replace_body_reports_import_error() {
let mut card = Card::new("note").unwrap();
let deep = ">".repeat(crate::error::MAX_NESTING_DEPTH + 5);
match card.revise_body(&deep) {
Err(EditError::Import(_)) => {}
other => panic!("expected Import, got {other:?}"),
}
}
#[test]
fn test_install_body_sets_directly() {
use quillmark_content::model::{Mark, MarkKind};
let mut content = quillmark_content::import::from_markdown("underlined body").unwrap();
content.marks.push(Mark {
start: 0,
end: 10,
kind: MarkKind::Underline,
});
content.normalize();
let mut card = Card::new("note").unwrap();
card.install_body(content.clone());
assert_eq!(card.body(), &content);
assert!(card
.body()
.marks
.iter()
.any(|m| matches!(m.kind, MarkKind::Underline)));
}
#[test]
fn test_install_field_sets_directly() {
use quillmark_content::model::{Mark, MarkKind};
let mut content = quillmark_content::import::from_markdown("underlined intro").unwrap();
content.marks.push(Mark {
start: 0,
end: 10,
kind: MarkKind::Underline,
});
content.normalize();
let mut card = Card::new("note").unwrap();
card.install_field("intro", content.clone()).unwrap();
let read = card.field_richtext("intro").unwrap().unwrap();
assert_eq!(read, content);
assert!(read.marks.iter().any(|m| matches!(m.kind, MarkKind::Underline)));
assert_eq!(
card.install_field("$bad", quillmark_content::Content::empty())
.unwrap_err()
.variant_name(),
"InvalidFieldName"
);
}
#[test]
fn test_revise_field_diff_imports_and_returns_delta() {
use quillmark_content::model::{Mark, MarkKind};
let mut card = Card::new("note").unwrap();
let delta = card.revise_field("intro", "hello target world").unwrap();
assert!(!delta.ops.is_empty());
let mut base = card.field_richtext("intro").unwrap().unwrap();
base.marks.push(Mark {
start: 6,
end: 12, kind: MarkKind::Anchor { id: "c1".into() },
});
base.normalize();
card.install_field("intro", base).unwrap();
card.revise_field("intro", "why keep the target here").unwrap();
let read = card.field_richtext("intro").unwrap().unwrap();
assert!(read
.marks
.iter()
.any(|m| matches!(&m.kind, MarkKind::Anchor { id } if id == "c1")));
card.store_field("count", crate::QuillValue::from_json(serde_json::json!(3)))
.unwrap();
assert_eq!(
card.revise_field("count", "x").unwrap_err().variant_name(),
"FieldRichtextDecode"
);
}
#[test]
fn test_revise_field_checked_preserves_anchors_and_enforces_inline() {
use crate::quill::{FieldSchema, FieldType};
use quillmark_content::model::{Mark, MarkKind};
let inline = FieldSchema::new(
"subject".to_string(),
FieldType::RichText { inline: true },
None,
);
let mut card = Card::new("note").unwrap();
let delta = card
.revise_field_checked("subject", "hello target world", &inline)
.unwrap();
assert!(!delta.ops.is_empty());
let mut base = card.field_richtext("subject").unwrap().unwrap();
base.marks.push(Mark {
start: 6,
end: 12, kind: MarkKind::Anchor { id: "c1".into() },
});
base.normalize();
card.install_field("subject", base).unwrap();
card.revise_field_checked("subject", "why keep the target here", &inline)
.unwrap();
let read = card.field_richtext("subject").unwrap().unwrap();
assert!(
read.marks
.iter()
.any(|m| matches!(&m.kind, MarkKind::Anchor { id } if id == "c1")),
"anchor should rebase onto surviving text, unlike the cold commit_field"
);
let before = card.field_markdown("subject").unwrap().unwrap();
let err = card
.revise_field_checked("subject", "line one\n\nline two", &inline)
.unwrap_err();
assert_eq!(err.variant_name(), "FieldRichtextNotInline");
assert_eq!(card.field_markdown("subject").unwrap().unwrap(), before);
let block = FieldSchema::new("body".to_string(), FieldType::RichText { inline: false }, None);
let d = card
.revise_field_checked("body", "para one\n\npara two", &block)
.unwrap();
assert!(!d.ops.is_empty());
assert!(card.field_markdown("body").unwrap().unwrap().contains("para two"));
}
#[test]
fn test_commit_field_richtext_content_object_reads_back() {
use quillmark_content::model::{Mark, MarkKind};
let mut content = quillmark_content::import::from_markdown("underlined intro").unwrap();
content.marks.push(Mark {
start: 0,
end: 10,
kind: MarkKind::Underline,
});
content.normalize();
let json = quillmark_content::serial::to_canonical_value(&content);
let mut card = Card::new("note").unwrap();
commit_richtext(&mut card, "intro", &json, false).unwrap();
assert!(card.payload().get("intro").unwrap().as_json().is_object());
let read = card.field_richtext("intro").unwrap().unwrap();
assert_eq!(read, content);
assert!(read.marks.iter().any(|m| matches!(m.kind, MarkKind::Underline)));
}
#[test]
fn test_commit_field_richtext_markdown_null_and_rejects_bad() {
let mut card = Card::new("note").unwrap();
commit_richtext(&mut card, "intro", &serde_json::json!("**bold** intro"), false).unwrap();
assert_eq!(card.field_markdown("intro").unwrap().unwrap(), "**bold** intro");
commit_richtext(&mut card, "intro", &serde_json::Value::Null, false).unwrap();
assert!(card.payload().get("intro").unwrap().as_json().is_null());
assert!(card.field_richtext("intro").unwrap().unwrap().is_blank());
assert_eq!(
commit_richtext(&mut card, "intro", &serde_json::json!({ "not": "a content" }), false)
.unwrap_err()
.variant_name(),
"FieldRichtextDecode"
);
assert_eq!(
commit_richtext(&mut card, "intro", &serde_json::json!(42), false)
.unwrap_err()
.variant_name(),
"FieldRichtextDecode"
);
}
#[test]
fn test_commit_field_richtext_inline_enforced_at_write() {
let mut card = Card::new("note").unwrap();
commit_richtext(&mut card, "title", &serde_json::json!("A single line"), true).unwrap();
assert_eq!(card.field_markdown("title").unwrap().unwrap(), "A single line");
let err = commit_richtext(
&mut card,
"title",
&serde_json::json!("line one\n\nline two"),
true,
)
.unwrap_err();
assert_eq!(err.variant_name(), "FieldRichtextNotInline");
assert_eq!(card.field_markdown("title").unwrap().unwrap(), "A single line");
}
#[test]
fn test_commit_field_array_of_inline_richtext_reports_not_inline() {
use crate::quill::{FieldSchema, FieldType};
let mut card = Card::new("note").unwrap();
let mut schema = FieldSchema::new("refs".to_string(), FieldType::Array, None);
schema.items = Some(Box::new(FieldSchema::new(
"refs".to_string(),
FieldType::RichText { inline: true },
None,
)));
card.commit_field(
"refs",
QuillValue::from_json(serde_json::json!(["one line"])),
&schema,
)
.unwrap();
let err = card
.commit_field(
"refs",
QuillValue::from_json(serde_json::json!(["line one\n\nline two"])),
&schema,
)
.unwrap_err();
assert_eq!(err.variant_name(), "FieldRichtextNotInline");
}
#[test]
fn test_commit_field_scalar_strict() {
use crate::quill::{FieldSchema, FieldType};
let mut card = Card::new("note").unwrap();
let int_schema = FieldSchema::new("qty".to_string(), FieldType::Integer, None);
card.commit_field("qty", QuillValue::from_json(serde_json::json!("3")), &int_schema)
.unwrap();
assert_eq!(
card.payload().get("qty").unwrap().as_json(),
&serde_json::json!(3)
);
let err = card
.commit_field("qty", QuillValue::from_json(serde_json::json!(true)), &int_schema)
.unwrap_err();
assert_eq!(err.variant_name(), "FieldConform");
assert_eq!(
card.commit_field("qty", QuillValue::from_json(serde_json::json!("x")), &int_schema)
.unwrap_err()
.variant_name(),
"FieldConform"
);
assert_eq!(
card.payload().get("qty").unwrap().as_json(),
&serde_json::json!(3)
);
}
#[test]
fn test_commit_field_object_rejects_non_object() {
use crate::quill::{FieldSchema, FieldType};
let mut card = Card::new("note").unwrap();
let schema = FieldSchema::new("meta".to_string(), FieldType::Object, None);
assert_eq!(
card.commit_field("meta", QuillValue::from_json(serde_json::json!(42)), &schema)
.unwrap_err()
.variant_name(),
"FieldConform"
);
}
#[test]
fn test_commit_field_rejects_bad_name() {
use crate::quill::{FieldSchema, FieldType};
let mut card = Card::new("note").unwrap();
let schema = FieldSchema::new("$bad".to_string(), FieldType::Integer, None);
assert_eq!(
card.commit_field("$bad", QuillValue::from_json(serde_json::json!(1)), &schema)
.unwrap_err()
.variant_name(),
"InvalidFieldName"
);
}
#[test]
fn test_field_richtext_absent_and_non_richtext() {
let mut card = Card::new("note").unwrap();
assert!(card.field_richtext("missing").is_none());
assert!(card.field_markdown("missing").is_none());
card.store_field("count", 3).unwrap();
assert!(card.field_richtext("count").unwrap().is_err());
assert!(card.field_markdown("count").unwrap().is_err());
}
#[test]
fn test_content_field_emits_as_markdown_projection() {
let mut doc = Document::new(QuillReference::from_str("test_quill").unwrap());
commit_richtext(
doc.main_mut(),
"intro",
&serde_json::json!("**bold** intro"),
false,
)
.unwrap();
let md = doc.to_markdown();
assert!(
md.contains("intro: \"**bold** intro\""),
"expected markdown projection, got:\n{md}"
);
assert!(!md.contains("lines:"), "content object leaked into card-yaml:\n{md}");
let reparsed = Document::parse(&md).unwrap().document;
assert_eq!(
reparsed.main().payload().get("intro").unwrap().as_str(),
Some("**bold** intro")
);
}
#[test]
fn test_non_content_object_field_emits_structurally() {
let mut doc = Document::new(QuillReference::from_str("test_quill").unwrap());
doc.main_mut()
.store_field(
"addr",
QuillValue::from_json(serde_json::json!({ "city": "Paris" })),
)
.unwrap();
let md = doc.to_markdown();
assert!(md.contains("addr:"), "{md}");
assert!(md.contains("city: Paris"), "{md}");
}
#[test]
fn test_noncanonical_order_content_field_stays_structural() {
let rt = quillmark_content::import::from_markdown("**bold**").unwrap();
let canonical = quillmark_content::serial::to_canonical_value(&rt);
let obj = canonical.as_object().unwrap();
let mut scrambled = serde_json::Map::new();
for k in obj.keys().rev() {
scrambled.insert(k.clone(), obj[k].clone());
}
let mut doc = Document::new(QuillReference::from_str("test_quill").unwrap());
doc.main_mut()
.store_field(
"intro",
QuillValue::from_json(serde_json::Value::Object(scrambled)),
)
.unwrap();
let md = doc.to_markdown();
assert!(
md.contains("marks:") && md.contains("lines:"),
"non-canonical-order content should stay structural, got:\n{md}"
);
}
#[test]
fn test_revise_body_returns_delta_and_updates_body() {
use crate::{Assoc, Delta};
let mut card = Card::new("note").unwrap();
card.revise_body("hello world").unwrap();
let delta: Delta = card.revise_body("hello brave world").unwrap();
assert_eq!(card.body().text, "hello brave world");
assert_eq!(delta.map_pos(6, Assoc::Before), 6);
assert_eq!(delta.map_pos(11, Assoc::After), 17);
}
#[test]
fn test_revise_body_rebases_anchor() {
use quillmark_content::model::{Mark, MarkKind};
let mut base = quillmark_content::import::from_markdown("keep the target word").unwrap();
base.marks.push(Mark {
start: 9,
end: 15,
kind: MarkKind::Anchor { id: "c1".into() },
});
base.normalize();
let mut card = Card::new("note").unwrap();
card.install_body(base);
card.revise_body("why keep the target word").unwrap();
let anchor = card
.body()
.marks
.iter()
.find(|m| matches!(&m.kind, MarkKind::Anchor { id } if id == "c1"))
.expect("identity anchor survives the whole-document replace");
let text = &card.body().text;
let s = quillmark_content::usv::char_to_byte(text, anchor.start);
let e = quillmark_content::usv::char_to_byte(text, anchor.end);
assert_eq!(&text[s..e], "target");
}
#[test]
fn test_apply_body_change_applies_bundle() {
use crate::MarkOp;
use quillmark_content::delta::diff;
use quillmark_content::model::MarkKind;
let mut card = Card::new("note").unwrap();
card.revise_body("abc").unwrap();
let d = diff("abc", "abXc");
card.apply_body_change(
&d,
&[],
&[MarkOp::Add {
start: 3,
end: 4,
kind: MarkKind::Strong,
}],
)
.unwrap();
assert_eq!(card.body().text, "abXc");
let strong = card
.body()
.marks
.iter()
.find(|m| matches!(m.kind, MarkKind::Strong))
.expect("strong mark applied post-delta");
assert_eq!((strong.start, strong.end), (3, 4));
}
#[test]
fn test_apply_body_change_reports_out_of_range() {
use crate::MarkOp;
use quillmark_content::delta::diff;
use quillmark_content::model::MarkKind;
let mut card = Card::new("note").unwrap();
card.revise_body("abc").unwrap();
let identity = diff("abc", "abc");
let result = card.apply_body_change(
&identity,
&[],
&[MarkOp::Add {
start: 0,
end: 99,
kind: MarkKind::Strong,
}],
);
match result {
Err(EditError::ContentApply(_)) => {}
other => panic!("expected ContentApply, got {other:?}"),
}
}
#[test]
fn test_apply_field_richtext_change_splices_and_persists() {
use crate::MarkOp;
use quillmark_content::delta::diff;
use quillmark_content::model::MarkKind;
let mut card = Card::new("note").unwrap();
commit_richtext(&mut card, "intro", &serde_json::json!("abc"), false).unwrap();
let d = diff("abc", "abXc");
card.apply_field_richtext_change(
"intro",
&d,
&[],
&[MarkOp::Add {
start: 3,
end: 4,
kind: MarkKind::Strong,
}],
)
.unwrap();
assert!(card.payload().get("intro").unwrap().as_json().is_object());
let rt = card.field_richtext("intro").unwrap().unwrap();
assert_eq!(rt.text, "abXc");
assert!(rt.marks.iter().any(|m| matches!(m.kind, MarkKind::Strong)));
}
#[test]
fn test_apply_field_richtext_change_rejects_non_richtext() {
use quillmark_content::delta::diff;
let mut card = Card::new("note").unwrap();
let identity = diff("", "");
assert_eq!(
card.apply_field_richtext_change("missing", &identity, &[], &[])
.unwrap_err()
.variant_name(),
"FieldRichtextDecode"
);
card.store_field("count", 3).unwrap();
assert_eq!(
card.apply_field_richtext_change("count", &identity, &[], &[])
.unwrap_err()
.variant_name(),
"FieldRichtextDecode"
);
}
#[test]
fn test_invariants_after_mutation_sequence() {
let mut doc = make_doc();
doc.main_mut().store_field("author", qv("Alice")).unwrap();
doc.main_mut().store_field("version", qv_int(3)).unwrap();
let c1 = Card::new("note").unwrap();
let c2 = Card::new("summary").unwrap();
let c3 = Card::new("appendix").unwrap();
doc.push_card(c1).unwrap();
doc.push_card(c2).unwrap();
doc.insert_card(1, c3).unwrap();
doc.card_mut(0)
.unwrap()
.store_field("text", qv("Hello"))
.unwrap();
doc.move_card(2, 0).unwrap();
doc.remove_card(1);
doc.main_mut().revise_body("Updated body.").unwrap();
doc.main_mut().remove_field("version").unwrap();
for key in doc.main().payload().keys() {
assert!(
is_valid_field_name(key),
"invalid key '{}' found in payload",
key
);
}
for card in doc.cards() {
if let Some(kind) = card.kind() {
assert!(is_valid_kind_name(kind), "invalid kind '{}' found", kind);
}
}
let json = doc.to_plate_json();
assert!(json.is_object());
assert_eq!(json["$quill"].as_str(), Some("test_quill"));
assert!(json["$cards"].is_array());
assert_eq!(json["$body"]["text"].as_str(), Some("Updated body."));
assert_eq!(
doc.main().payload().get("author").unwrap().as_str(),
Some("Alice")
);
assert!(doc.main().payload().get("version").is_none());
}
#[test]
fn test_store_ext_adds_map_and_strips_from_plate() {
let mut doc = make_doc();
let mut ext = serde_json::Map::new();
ext.insert(
"presentation".to_string(),
serde_json::json!({ "title": "Greeting" }),
);
doc.main_mut().store_ext(ext).expect("set_ext");
assert_eq!(
doc.main().ext().unwrap()["presentation"]["title"].as_str(),
Some("Greeting")
);
let json = doc.to_plate_json();
assert!(json.get("$ext").is_none());
assert!(!json.as_object().unwrap().contains_key("$ext"));
}
#[test]
fn test_store_ext_round_trips_through_markdown() {
let mut doc = make_doc();
let mut ext = serde_json::Map::new();
ext.insert("agent".to_string(), serde_json::json!({ "pinned": true }));
doc.main_mut().store_ext(ext).expect("set_ext");
let md = doc.to_markdown();
let reparsed = Document::parse(&md).unwrap().document;
assert_eq!(
reparsed.main().ext().unwrap()["agent"]["pinned"].as_bool(),
Some(true)
);
}
#[test]
fn test_remove_ext_returns_previous_and_clears() {
let mut doc = make_doc();
let mut ext = serde_json::Map::new();
ext.insert("agent".to_string(), serde_json::json!(1));
doc.main_mut().store_ext(ext).expect("set_ext");
let removed = doc.main_mut().remove_ext().unwrap();
assert_eq!(removed["agent"].as_i64(), Some(1));
assert!(doc.main().ext().is_none());
assert!(doc.main_mut().remove_ext().is_none());
}
#[test]
fn test_store_ext_namespace_preserves_siblings() {
let mut doc = make_doc();
doc.main_mut()
.store_ext_namespace("presentation", serde_json::json!({ "title": "A" }))
.expect("set_ext_namespace");
doc.main_mut()
.store_ext_namespace("agent", serde_json::json!({ "pinned": true }))
.expect("set_ext_namespace");
let ext = doc.main().ext().unwrap();
assert_eq!(ext["presentation"]["title"].as_str(), Some("A"));
assert_eq!(ext["agent"]["pinned"].as_bool(), Some(true));
doc.main_mut()
.store_ext_namespace("presentation", serde_json::json!({ "title": "B" }))
.expect("set_ext_namespace");
let ext = doc.main().ext().unwrap();
assert_eq!(ext["presentation"]["title"].as_str(), Some("B"));
assert_eq!(ext["agent"]["pinned"].as_bool(), Some(true));
}
#[test]
fn test_remove_ext_namespace_preserves_siblings() {
let mut doc = make_doc();
doc.main_mut()
.store_ext_namespace("presentation", serde_json::json!({ "title": "A" }))
.expect("set_ext_namespace");
doc.main_mut()
.store_ext_namespace("tutorial", serde_json::json!(["step-1", "step-2"]))
.expect("set_ext_namespace");
let removed = doc.main_mut().remove_ext_namespace("tutorial").unwrap();
assert_eq!(removed, serde_json::json!(["step-1", "step-2"]));
let ext = doc.main().ext().unwrap();
assert_eq!(ext["presentation"]["title"].as_str(), Some("A"));
assert!(!ext.contains_key("tutorial"));
}
#[test]
fn test_remove_ext_namespace_drops_ext_when_empty() {
let mut doc = make_doc();
doc.main_mut()
.store_ext_namespace("tutorial", serde_json::json!(["step-1"]))
.expect("set_ext_namespace");
let removed = doc.main_mut().remove_ext_namespace("tutorial").unwrap();
assert_eq!(removed, serde_json::json!(["step-1"]));
assert!(doc.main().ext().is_none());
}
#[test]
fn test_remove_ext_namespace_is_noop_when_absent() {
let mut doc = make_doc();
assert!(doc.main_mut().remove_ext_namespace("tutorial").is_none());
doc.main_mut()
.store_ext_namespace("presentation", serde_json::json!({ "title": "A" }))
.expect("set_ext_namespace");
assert!(doc.main_mut().remove_ext_namespace("tutorial").is_none());
assert_eq!(
doc.main().ext().unwrap()["presentation"]["title"].as_str(),
Some("A")
);
}
#[test]
fn test_set_empty_ext_is_preserved() {
let mut doc = make_doc();
doc.main_mut()
.store_ext(serde_json::Map::new())
.expect("set_ext");
assert!(doc.main().ext().is_some());
let md = doc.to_markdown();
assert!(md.contains("$ext: {}"), "got: {md}");
}
#[test]
fn test_ext_mutators_work_on_composable_cards() {
let mut doc = make_doc_with_cards();
doc.card_mut(0)
.unwrap()
.store_ext_namespace("agent", serde_json::json!({ "note": "x" }))
.expect("set_ext_namespace");
assert_eq!(
doc.cards()[0].ext().unwrap()["agent"]["note"].as_str(),
Some("x")
);
let removed = doc.card_mut(0).unwrap().remove_ext_namespace("agent");
assert_eq!(removed, Some(serde_json::json!({ "note": "x" })));
assert!(doc.cards()[0].ext().is_none());
}
fn deep_value(depth: usize) -> serde_json::Value {
let mut v = serde_json::json!(1);
for _ in 0..depth {
let mut m = serde_json::Map::new();
m.insert("a".to_string(), v);
v = serde_json::Value::Object(m);
}
v
}
#[test]
fn store_field_rejects_value_past_depth_limit() {
let mut doc =
crate::document::Document::parse("~~~\n$quill: q@1.0\n$kind: main\n~~~\n").unwrap().document;
let ok = crate::value::QuillValue::from_json(deep_value(50));
assert!(doc.main_mut().store_field("x", ok).is_ok());
let too_deep = crate::value::QuillValue::from_json(deep_value(150));
let err = doc.main_mut().store_field("y", too_deep).unwrap_err();
assert!(
matches!(err, crate::document::EditError::ValueTooDeep { max: 100 }),
"expected ValueTooDeep, got {err:?}"
);
let too_deep = crate::value::QuillValue::from_json(deep_value(150));
assert!(doc.main_mut().store_fill("y", too_deep).is_err());
let serde_json::Value::Object(map) = deep_value(150) else {
unreachable!()
};
assert!(doc.main_mut().store_ext(map).is_err());
assert!(doc
.main_mut()
.store_ext_namespace("ns", deep_value(150))
.is_err());
}
#[test]
fn storage_dto_rejects_value_past_depth_limit() {
let stored = serde_json::json!({
"schema": "quillmark/document@0.92.0",
"main": {
"payload": {"items": [
{"type": "quill", "value": "q@1.0"},
{"type": "kind", "value": "main"},
{"type": "field", "key": "x", "value": deep_value(150)}
]},
"body": ""
},
"cards": []
});
let err = serde_json::from_value::<crate::document::Document>(stored).unwrap_err();
assert!(
err.to_string().contains("deeper than the maximum"),
"expected depth error, got {err}"
);
let serde_json::Value::Object(deep_map) = deep_value(150) else {
unreachable!()
};
let stored = serde_json::json!({
"schema": "quillmark/document@0.92.0",
"main": {
"payload": {"items": [
{"type": "quill", "value": "q@1.0"},
{"type": "kind", "value": "main"},
{"type": "ext", "value": deep_map}
]},
"body": ""
},
"cards": []
});
let err = serde_json::from_value::<crate::document::Document>(stored).unwrap_err();
assert!(
err.to_string().contains("deeper than the maximum"),
"expected $ext depth error, got {err}"
);
}
#[test]
fn wire_card_rejects_value_past_depth_limit_and_bad_names() {
let wire: crate::document::CardWire = serde_json::from_value(serde_json::json!({
"kind": "note",
"payloadItems": [
{"type": "field", "key": "x", "value": deep_value(150)}
],
"body": ""
}))
.unwrap();
let err = crate::document::Card::try_from(wire).unwrap_err();
assert!(
err.to_string().contains("deeper than the maximum"),
"expected depth error, got {err}"
);
let wire: crate::document::CardWire = serde_json::from_value(serde_json::json!({
"kind": "note",
"payloadItems": [
{"type": "field", "key": "Bad Name", "value": 1}
],
"body": ""
}))
.unwrap();
let err = crate::document::Card::try_from(wire).unwrap_err();
assert!(
err.to_string().contains("[A-Za-z_]"),
"expected name error, got {err}"
);
}
#[test]
fn find_card_returns_first_match_and_card_indexes() {
let mut doc = Document::new(QuillReference::from_str("test_quill").unwrap());
let mut c0 = Card::new("item").unwrap();
c0.payload_mut().set_id("dup");
let mut c1 = Card::new("item").unwrap();
c1.payload_mut().set_id("other");
let mut c2 = Card::new("item").unwrap();
c2.payload_mut().set_id("dup");
doc.push_card(c0).unwrap();
doc.push_card(c1).unwrap();
doc.push_card(c2).unwrap();
let (idx, card) = doc.find_card("dup").unwrap();
assert_eq!(idx, 0);
assert_eq!(card.id(), Some("dup"));
assert_eq!(doc.find_card("other").map(|(i, _)| i), Some(1));
assert!(doc.find_card("missing").is_none());
assert_eq!(doc.card(1).unwrap().id(), Some("other"));
assert!(doc.card(3).is_none());
}