Files
wordcraft/crates/engine/src/cmd/references/tests.rs
T
2026-11-04 16:08:39 +01:00

900 lines
46 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use super::*;
fn fields(s: &Session) -> Vec<(String, String)> {
let mut out = Vec::new();
for path in s.doc.para_paths(StoryRef::Body) {
for o in &s.doc.para(StoryRef::Body, &path).unwrap().objects {
if let InlineObject::Field { instr, result, .. } = o {
out.push((instr.clone(), result.clone()));
}
}
}
out
}
/// #775: body STYLEREF fields show the nearest paragraph of the style before them.
#[test]
fn body_styleref_finds_the_heading_before_it() {
let mut s = Session::new(wordcraft_doc::Document::from_text("Intro\nChapter One\ntext here\nChapter Two\nend"));
for t in ["Chapter One", "Chapter Two"] {
s.run("select.text", &json!({"text": t})).unwrap();
s.run("styles.apply", &json!({"style": "Heading 1"})).unwrap();
}
s.run("select.text", &json!({"text": "text here"})).unwrap();
s.run("caret.end", &json!({})).unwrap();
s.run("insert.field", &json!({"instr": r#"STYLEREF "Heading 1" \* MERGEFORMAT"#})).unwrap();
s.run("caret.docEnd", &json!({})).unwrap();
s.run("insert.field", &json!({"instr": "STYLEREF 1 \\p"})).unwrap();
s.run("caret.docStart", &json!({})).unwrap();
s.run("insert.field", &json!({"instr": "STYLEREF \"Heading 1\" \\p"})).unwrap();
s.run("insert.field", &json!({"instr": "STYLEREF \"No Such Style\""})).unwrap();
let results: Vec<String> = fields(&s).into_iter().map(|f| f.1).collect();
assert_eq!(results, ["below", wordcraft_layout::styleref::NO_TEXT, "Chapter One", "above"]);
}
/// #775: a template's running header `STYLEREF "heading 1"` (a complex field, opened from
/// DOCX) shows each page's chapter, and keeps its code through a save.
#[test]
fn docx_styleref_header_shows_each_page_s_chapter() {
let ns = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships""#;
let heading = |t: &str, brk: bool| {
let b = if brk { "<w:pageBreakBefore/>" } else { "" };
format!(r#"<w:p><w:pPr><w:pStyle w:val="Heading1"/>{b}</w:pPr><w:r><w:t>{t}</w:t></w:r></w:p><w:p><w:r><w:t>Body of {t}.</w:t></w:r></w:p>"#)
};
let body = format!(
r#"<?xml version="1.0" encoding="UTF-8"?><w:document {ns}><w:body>{}{}{}<w:sectPr><w:headerReference w:type="default" r:id="rIdH"/></w:sectPr></w:body></w:document>"#,
heading("Rivers", false),
heading("Mountains", true),
heading("Deserts", true)
);
let header = format!(
r#"<w:hdr {ns}><w:p><w:r><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:instrText xml:space="preserve"> STYLEREF "heading 1" \* MERGEFORMAT </w:instrText></w:r><w:r><w:fldChar w:fldCharType="separate"/></w:r><w:r><w:t>Cached</w:t></w:r><w:r><w:fldChar w:fldCharType="end"/></w:r></w:p></w:hdr>"#
);
let styles = format!(
r#"<w:styles {ns}><w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/></w:style><w:style w:type="paragraph" w:styleId="Heading1"><w:name w:val="heading 1"/><w:basedOn w:val="Normal"/></w:style></w:styles>"#
);
let root = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
let rels = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rIdH" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/header" Target="header1.xml"/><Relationship Id="rIdS" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>"#;
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
for (name, text) in [
("_rels/.rels", root),
("word/document.xml", body.as_str()),
("word/_rels/document.xml.rels", rels),
("word/header1.xml", header.as_str()),
("word/styles.xml", styles.as_str()),
] {
zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
std::io::Write::write_all(&mut zw, text.as_bytes()).unwrap();
}
let bytes = zw.finish().unwrap().into_inner();
let headers = |s: &mut Session| -> Vec<String> {
s.layout()
.pages
.iter()
.map(|p| {
p.header
.iter()
.filter_map(|it| {
if let wordcraft_layout::Placed::Lines { para, .. } = it {
Some(para.shown.iter().map(|x| x.1.clone()).collect::<String>())
} else {
None
}
})
.collect::<String>()
})
.collect()
};
let mut s = Session::new(crate::io::open_bytes("chapters.docx", &bytes).unwrap());
assert_eq!(headers(&mut s), ["Rivers", "Mountains", "Deserts"]);
let saved = crate::io::save_bytes("chapters.docx", &s.doc).unwrap();
let mut again = Session::new(crate::io::open_bytes("chapters.docx", &saved).unwrap());
assert_eq!(headers(&mut again), ["Rivers", "Mountains", "Deserts"]);
let hid = again.doc.last_section.headers.default.unwrap();
let instr = again.doc.para_paths(StoryRef::Part(hid)).iter().find_map(|p| {
again.doc.para(StoryRef::Part(hid), p).unwrap().objects.iter().find_map(|o| match o {
InlineObject::Field { instr, .. } => Some(instr.clone()),
_ => None,
})
});
assert!(instr.is_some_and(|i| i.contains(r#"STYLEREF "heading 1""#)));
}
/// #402: Custom Table of Contents settings become TOC switches, and updating honours them:
/// levels (`\o`), a style with a level (`\t`), no hyperlinks, page numbers after a space
/// (`\p`), or none at all (`\n`).
#[test]
fn toc_switches_shape_the_entries() {
let mut s = Session::new(wordcraft_doc::Document::from_text("Intro\nPart\nDeep\nNote"));
for (text, style) in [("Intro", "Heading 1"), ("Part", "Heading 2"), ("Deep", "Heading 3"), ("Note", "Quote")] {
s.run("select.text", &json!({"text": text})).unwrap();
s.run("styles.apply", &json!({"style": style})).unwrap();
}
s.run("caret.docStart", &json!({})).unwrap();
s.run("references.toc", &json!({"levels": 2, "hyperlinks": false, "rightAlign": false, "styleLevels": {"Quote": 2}})).unwrap();
assert_eq!(fields(&s)[0].0, "TOC \\o \"1-2\" \\z \\u \\p \" \" \\t \"Quote,2\"");
let entries: Vec<(String, String)> = s
.doc
.body
.iter()
.filter_map(|b| b.as_para())
.filter(|p| p.props.style.as_deref().is_some_and(|st| st.starts_with("TOC") && st != "TOCHeading"))
.map(|p| (p.props.style.clone().unwrap(), p.plain_text()))
.collect();
assert_eq!(entries, [("TOC1", "Intro 1"), ("TOC2", "Part 1"), ("TOC2", "Note 1")].map(|(a, b)| (a.to_string(), b.to_string())));
assert!(s.doc.bookmarks().is_empty(), "no hyperlinks, no _Toc bookmarks");
// Settings again replace the table in place; hyperlinks bookmark the headings.
s.run("references.toc", &json!({"levels": 3, "pageNumbers": false})).unwrap();
assert_eq!(s.doc.plain_text(StoryRef::Body).matches("Contents").count(), 1);
let toc: Vec<String> =
s.doc.body.iter().filter_map(|b| b.as_para()).filter(|p| p.props.style.as_deref() == Some("TOC3")).map(|p| p.plain_text()).collect();
assert_eq!(toc, ["Deep"]);
let first = s.doc.body.iter().filter_map(|b| b.as_para()).find(|p| p.props.style.as_deref() == Some("TOC1")).unwrap();
let link = first.runs[0].props.link.clone().unwrap();
assert!(s.doc.bookmarks().iter().any(|(n, _)| format!("#{n}") == link), "{link}");
}
/// #402: Caption numbering — a custom label above the paragraph, letters, and chapter
/// numbers that restart after each Heading 1 (`STYLEREF 1 \s`, `SEQ … \s 1`).
#[test]
fn caption_numbering_with_chapters() {
let mut s = Session::new(wordcraft_doc::Document::from_text("One\nPic\nPic two\nTwo\nPic three"));
for t in ["One", "Two"] {
s.run("select.text", &json!({"text": t})).unwrap();
s.run("styles.apply", &json!({"style": "Heading 1"})).unwrap();
}
let opts = json!({"label": "Photo Plate", "format": "upperLetter", "chapter": 1, "separator": "."});
for t in ["Pic three", "Pic two", "Pic"] {
s.run("select.text", &json!({"text": t})).unwrap();
let mut v = opts.clone();
v["position"] = json!("above");
v["text"] = json!(t);
s.run("references.caption", &v).unwrap();
}
let caps: Vec<String> =
s.doc.body.iter().filter_map(|b| b.as_para()).filter(|p| p.props.style.as_deref() == Some("Caption")).map(|p| p.plain_text()).collect();
assert_eq!(caps, ["Photo Plate 1.A: Pic", "Photo Plate 1.B: Pic two", "Photo Plate 2.A: Pic three"]);
assert!(fields(&s).contains(&("SEQ Photo_Plate \\* ALPHABETIC \\s 1".to_string(), "A".to_string())), "{:?}", fields(&s));
// The caption sits above its paragraph; excluding the label leaves the number.
let i = s.doc.body.iter().position(|b| b.as_para().is_some_and(|p| p.plain_text() == "Pic")).unwrap();
assert_eq!(s.doc.body[i - 1].as_para().unwrap().plain_text(), "Photo Plate 1.A: Pic");
s.run("caret.docEnd", &json!({})).unwrap();
s.run("references.caption", &json!({"label": "Table", "excludeLabel": true, "format": "ROMAN"})).unwrap();
assert_eq!(s.doc.body.last().unwrap().as_para().unwrap().plain_text(), "I");
}
#[test]
fn update_fields_ref_and_pageref() {
let mut s = Session::new(wordcraft_doc::Document::from_text("Bookmark target text here.\nSecond paragraph."));
s.run("select.text", &json!({"text": "Bookmark target text"})).unwrap();
s.run("insert.bookmark", &json!({"name": "TargetMark"})).unwrap();
s.run("caret.docEnd", &json!({})).unwrap();
s.run("insert.field", &json!({"instr": "REF \"TargetMark\" \\h", "result": "old"})).unwrap();
s.run("insert.field", &json!({"instr": "REF TargetMark\\h", "result": "old"})).unwrap();
s.run("insert.field", &json!({"instr": "PAGEREF \"TargetMark\" \\* Roman", "result": "0"})).unwrap();
s.run("insert.field", &json!({"instr": "PAGEREF TargetMark \\p", "result": "above"})).unwrap();
s.run("insert.field", &json!({"instr": "REF TargetMark \\p", "result": "above"})).unwrap();
update_fields(&mut s).unwrap();
let res: Vec<(String, String)> = fields(&s);
assert_eq!(res[0], ("REF \"TargetMark\" \\h".into(), "Bookmark target text".into()));
assert_eq!(res[1], ("REF TargetMark\\h".into(), "Bookmark target text".into()));
assert_eq!(res[2], ("PAGEREF \"TargetMark\" \\* Roman".into(), "I".into()));
assert_eq!(res[3], ("PAGEREF TargetMark \\p".into(), "above".into()));
assert_eq!(res[4], ("REF TargetMark \\p".into(), "above".into()));
}
/// A document with a body AUTHOR field and a header holding AUTHOR, NUMWORDS and a locked TITLE,
/// all stale; the author is "Ada".
fn doc_with_header_fields() -> (wordcraft_doc::Document, u32) {
use wordcraft_doc::{CharProps, Paragraph, PartKind};
let field = |instr: &str, locked: bool| InlineObject::Field {
instr: instr.into(),
result: "stale".into(),
locked,
dirty: false,
code: false,
form: String::new(),
};
let mut doc = wordcraft_doc::Document::from_text("one two three");
doc.core.creator = "Ada".into();
let mut p = Paragraph::new();
for (instr, locked) in [("AUTHOR", false), ("NUMWORDS", false), ("TITLE", true)] {
let off = p.len();
p.insert_object(off, field(instr, locked), &CharProps::default()).unwrap();
}
let body_path = doc.para_paths(StoryRef::Body).remove(0);
doc.para_mut(StoryRef::Body, &body_path).unwrap().insert_object(0, field("AUTHOR", false), &CharProps::default()).unwrap();
let hdr = doc.add_part(PartKind::Header, vec![std::sync::Arc::new(wordcraft_doc::Block::Para(p))]);
(doc, hdr)
}
fn story_fields(s: &Session, story: StoryRef) -> Vec<(String, String)> {
let mut out = Vec::new();
for path in s.doc.para_paths(story) {
for o in &s.doc.para(story, &path).unwrap().objects {
if let InlineObject::Field { instr, result, .. } = o {
out.push((instr.clone(), result.clone()));
}
}
}
out
}
#[test]
fn update_fields_filename() {
let mut s = Session::new(wordcraft_doc::Document::new());
s.path = Some(std::path::PathBuf::from("/docs/my_thesis.docx"));
s.run("insert.field", &serde_json::json!({"instr": "FILENAME"})).unwrap();
let fields = story_fields(&s, StoryRef::Body);
assert_eq!(fields[0], ("FILENAME".into(), "my_thesis.docx".into()));
s.run("insert.field", &serde_json::json!({"instr": r"FILENAME \p"})).unwrap();
let fields = story_fields(&s, StoryRef::Body);
assert_eq!(fields[1], (r"FILENAME \p".into(), "/docs/my_thesis.docx".into()));
}
#[test]
fn update_fields_filename_titled_unsaved() {
let mut doc = wordcraft_doc::Document::new();
doc.core.title = "Important Document".into();
let mut s = Session::new(doc);
s.run("insert.field", &serde_json::json!({"instr": "FILENAME"})).unwrap();
let fields = story_fields(&s, StoryRef::Body);
assert_eq!(fields[0], ("FILENAME".into(), "Document1".into()));
let l = s.layout();
let text: String = l.pages[0]
.items
.iter()
.filter_map(|it| {
if let wordcraft_layout::Placed::Lines { para, .. } = it {
Some(para.shown.iter().map(|x| x.1.clone()).collect::<String>())
} else {
None
}
})
.collect();
assert!(text.contains("Document1"), "live layout renders Document1: {text}");
assert!(!text.contains("Important Document"), "live layout must not render title: {text}");
s.run("references.updateFields", &serde_json::json!({})).unwrap();
let fields_after = story_fields(&s, StoryRef::Body);
assert_eq!(fields_after[0], ("FILENAME".into(), "Document1".into()));
}
/// #1706 (Word check): Update Field with the caret in the body leaves a header's fields alone.
#[test]
fn update_fields_in_the_body_leaves_headers_alone() {
let (doc, hdr) = doc_with_header_fields();
let mut s = Session::new(doc);
s.run("references.updateFields", &json!({})).unwrap();
assert_eq!(story_fields(&s, StoryRef::Body)[0], ("AUTHOR".to_string(), "Ada".to_string()));
assert!(story_fields(&s, StoryRef::Part(hdr)).iter().all(|(_, r)| r == "stale"), "{:?}", story_fields(&s, StoryRef::Part(hdr)));
}
/// #1706: with the caret in a header (or other part), Update Field refreshes that part's
/// fields, but not locked ones and not the body's.
#[test]
fn update_fields_in_a_header_updates_that_header() {
let (doc, hdr) = doc_with_header_fields();
let mut s = Session::new(doc);
let path = s.doc.para_paths(StoryRef::Part(hdr)).remove(0);
s.sel = crate::session::Selection::caret(Pos::new(StoryRef::Part(hdr), path, 0));
s.run("references.updateFields", &json!({})).unwrap();
let got = story_fields(&s, StoryRef::Part(hdr));
assert_eq!(got.len(), 3, "{got:?}");
assert_eq!(got[0].1, "Ada");
assert_eq!(got[1].1, s.doc.word_count().to_string());
assert_eq!(got[2].1, "stale", "a locked field stays as it was");
assert!(story_fields(&s, StoryRef::Body).iter().all(|(_, r)| r == "stale"), "the body is not touched");
}
/// A session whose body reads "one two three" and whose header holds stale fields with
/// these codes, the caret in the header.
fn header_fields_session(doc: wordcraft_doc::Document, instrs: &[&str]) -> (Session, u32) {
use wordcraft_doc::{CharProps, Paragraph, PartKind};
let mut p = Paragraph::new();
for instr in instrs {
let off = p.len();
let field =
InlineObject::Field { instr: (*instr).into(), result: "stale".into(), locked: false, dirty: false, code: false, form: String::new() };
p.insert_object(off, field, &CharProps::default()).unwrap();
}
let mut doc = doc;
let hdr = doc.add_part(PartKind::Header, vec![std::sync::Arc::new(wordcraft_doc::Block::Para(p))]);
let mut s = Session::new(doc);
let path = s.doc.para_paths(StoryRef::Part(hdr)).remove(0);
s.sel = crate::session::Selection::caret(Pos::new(StoryRef::Part(hdr), path, 0));
(s, hdr)
}
/// SUBJECT, KEYWORDS, COMMENTS, LASTSAVEDBY and REVNUM show the document properties, and
/// NUMCHARS counts the body's characters without spaces; the counts take a `\*` format.
#[test]
fn document_information_fields_show_the_properties() {
let mut doc = wordcraft_doc::Document::from_text("one two three");
doc.core.subject = "Budget".into();
doc.core.keywords = "plan; costs".into();
doc.core.description = "Draft for review".into();
doc.core.last_modified_by = "Grace".into();
doc.core.revision = 7;
let codes = ["SUBJECT", "KEYWORDS", "COMMENTS", "LASTSAVEDBY", "REVNUM", r"REVNUM \* ROMAN", "NUMCHARS", r"NUMWORDS \* roman"];
let (mut s, hdr) = header_fields_session(doc, &codes);
s.run("references.updateFields", &json!({})).unwrap();
let got: Vec<String> = story_fields(&s, StoryRef::Part(hdr)).into_iter().map(|(_, r)| r).collect();
assert_eq!(got, ["Budget", "plan; costs", "Draft for review", "Grace", "7", "VII", "11", "iii"]);
}
/// SET gives a name a value (and is drawn as nothing); REF shows it, a REF before the SET the value it
/// ends up with (an ODT's variables open as SET and REF).
#[test]
fn set_gives_a_value_that_ref_shows() {
let doc = wordcraft_doc::Document::from_text("one");
let codes = [
r#"SET who "Ada Lovelace""#,
"REF who",
r"REF WHO \* Upper",
"REF later",
"SET later Bob",
r#"SET who "Grace""#,
"REF who",
"SET",
"REF nothing",
];
let (mut s, hdr) = header_fields_session(doc, &codes);
s.run("references.updateFields", &json!({})).unwrap();
let got: Vec<String> = story_fields(&s, StoryRef::Part(hdr)).into_iter().map(|(_, r)| r).collect();
assert_eq!([&got[1], &got[2], &got[3], &got[6]], ["Ada Lovelace", "ADA LOVELACE", "Bob", "Grace"]);
}
/// USERNAME and USERINITIALS show the user's name and initials from File › Options (an ODT's
/// `text:author-name` opens as USERNAME); a quoted text after the name is shown instead.
#[test]
fn user_name_fields_show_the_users_name() {
let doc = wordcraft_doc::Document::from_text("one");
let codes = ["USERNAME", "USERINITIALS", r"USERNAME \* Upper", r#"USERNAME "Someone Else""#];
let (mut s, hdr) = header_fields_session(doc, &codes);
s.author = "Ada Lovelace".into();
s.initials = String::new();
s.run("references.updateFields", &json!({})).unwrap();
let got: Vec<String> = story_fields(&s, StoryRef::Part(hdr)).into_iter().map(|(_, r)| r).collect();
assert_eq!(got, ["Ada Lovelace", "AL", "ADA LOVELACE", "Someone Else"]);
}
/// A quoted text after TITLE, SUBJECT, AUTHOR, KEYWORDS or COMMENTS replaces the property
/// (at most 255 characters); the field and later ones, DOCPROPERTY too, show the new value.
#[test]
fn summary_fields_with_a_new_value_replace_the_property() {
let mut doc = wordcraft_doc::Document::from_text("one two three");
doc.core.subject = "Old".into();
let long = "x".repeat(300);
let long_code = format!("COMMENTS \"{long}\"");
let codes = [
r#"SUBJECT "Quarterly report""#,
"SUBJECT",
r#"TITLE "Plan" \* MERGEFORMAT"#,
r#"AUTHOR "Ada""#,
r#"KEYWORDS "a, b""#,
&long_code,
"DOCPROPERTY Subject",
];
let (mut s, hdr) = header_fields_session(doc, &codes);
s.run("references.updateFields", &json!({})).unwrap();
let got: Vec<String> = story_fields(&s, StoryRef::Part(hdr)).into_iter().map(|(_, r)| r).collect();
assert_eq!(got[..5], ["Quarterly report", "Quarterly report", "Plan", "Ada", "a, b"]);
assert_eq!(got[5].chars().count(), 255);
assert_eq!(got[6], "Quarterly report", "DOCPROPERTY reads the subject the paragraph set");
let c = &s.doc.core;
assert_eq!((c.subject.as_str(), c.title.as_str(), c.creator.as_str(), c.keywords.as_str()), ("Quarterly report", "Plan", "Ada", "a, b"));
assert_eq!(c.description, got[5]);
}
/// One word after the name sets the property without quotes; a switch alone sets nothing.
#[test]
fn summary_fields_take_one_word_without_quotes() {
let mut doc = wordcraft_doc::Document::from_text("one two three");
doc.core.subject = "Old".into();
doc.core.creator = "Grace".into();
doc.core.title = "Plan".into();
let (mut s, hdr) = header_fields_session(doc, &["SUBJECT Draft", "AUTHOR Ada", r"TITLE \* Upper"]);
s.run("references.updateFields", &json!({})).unwrap();
let got: Vec<String> = story_fields(&s, StoryRef::Part(hdr)).into_iter().map(|(_, r)| r).collect();
assert_eq!(got, ["Draft", "Ada", "PLAN"]);
let c = &s.doc.core;
assert_eq!((c.subject.as_str(), c.creator.as_str(), c.title.as_str()), ("Draft", "Ada", "Plan"));
}
/// `\* Upper`, `\* Lower`, `\* Caps` and `\* FirstCap` apply to the text Update Field computes,
/// and `\* CardText` and `\* OrdText` are lowercase unless one of them follows.
#[test]
fn update_fields_apply_the_capitalization_switches() {
let mut doc = wordcraft_doc::Document::from_text("one two three");
doc.core.subject = "budget".into();
doc.core.creator = "Ada LOVELACE".into();
doc.core.title = "weekly report on sales".into();
doc.core.description = "weekly report on sales".into();
doc.core.last_modified_by = "grace".into();
doc.core.revision = 7;
let codes = [
r"SUBJECT \* Upper",
r"AUTHOR \* Lower",
r"TITLE \* Caps \* MERGEFORMAT",
r"COMMENTS \*FirstCap",
r"LASTSAVEDBY \* upper",
r"NUMWORDS \* CardText",
r"NUMWORDS \* CardText \* Caps",
r"REVNUM \* OrdText",
r"REVNUM \* OrdText \* FirstCap",
r"DOCPROPERTY Subject \* Upper",
r#"SUBJECT "new \* Upper""#,
];
let (mut s, hdr) = header_fields_session(doc, &codes);
s.run("references.updateFields", &json!({})).unwrap();
let got: Vec<String> = story_fields(&s, StoryRef::Part(hdr)).into_iter().map(|(_, r)| r).collect();
assert_eq!(
got,
[
"BUDGET",
"ada lovelace",
"Weekly Report On Sales",
"Weekly report on sales",
"GRACE",
"three",
"Three",
"seventh",
"Seventh",
"BUDGET",
"new \\* Upper"
]
);
}
/// The capitalization switches also apply to REF, PAGEREF, SEQ and FILENAME results in the body.
#[test]
fn body_fields_apply_the_capitalization_switches() {
let mut s = Session::new(wordcraft_doc::Document::from_text("Bookmark target text here.\nSecond paragraph."));
s.run("select.text", &json!({"text": "Bookmark target text"})).unwrap();
s.run("insert.bookmark", &json!({"name": "TargetMark"})).unwrap();
s.run("caret.docEnd", &json!({})).unwrap();
for instr in [r"REF TargetMark \* Upper", r"PAGEREF TargetMark \* CardText", r"SEQ Figure \* OrdText \* Caps", r"FILENAME \* Lower"] {
s.run("insert.field", &json!({"instr": instr, "result": "old"})).unwrap();
}
update_fields(&mut s).unwrap();
let got: Vec<String> = fields(&s).into_iter().map(|(_, r)| r).collect();
let name = s.resolved_filenames().0.to_lowercase();
assert_eq!(got, ["BOOKMARK TARGET TEXT".to_string(), "one".into(), "First".into(), name]);
}
/// A count the field's number format does not cover keeps its digits.
#[test]
fn counts_past_the_number_format_keep_their_digits() {
let mut doc = wordcraft_doc::Document::from_text("one two three");
doc.core.revision = 1_000_000;
let codes = [r"REVNUM \* ROMAN", r"REVNUM \* CardText", r"REVNUM \* alphabetic", r"NUMWORDS \* ROMAN"];
let (mut s, hdr) = header_fields_session(doc, &codes);
s.run("references.updateFields", &json!({})).unwrap();
let got: Vec<String> = story_fields(&s, StoryRef::Part(hdr)).into_iter().map(|(_, r)| r).collect();
assert_eq!(got, ["1000000", "1000000", "1000000", "III"]);
}
/// #1706: `all: true` (printing) updates the whole document even with the caret in a header.
#[test]
fn update_fields_all_covers_every_story() {
let (doc, hdr) = doc_with_header_fields();
let mut s = Session::new(doc);
let path = s.doc.para_paths(StoryRef::Part(hdr)).remove(0);
s.sel = crate::session::Selection::caret(Pos::new(StoryRef::Part(hdr), path, 0));
s.run("references.updateFields", &json!({"all": true})).unwrap();
let got = story_fields(&s, StoryRef::Part(hdr));
assert_eq!((got[0].1.as_str(), got[2].1.as_str()), ("Ada", "stale"), "{got:?}");
assert_eq!(story_fields(&s, StoryRef::Body)[0].1, "Ada");
// Junk values fall back to the caret's story.
s.run("references.updateFields", &json!({"all": "yes"})).unwrap();
}
#[test]
fn toc_tabs_ignore_nonentries() {
let mut s = Session::new(wordcraft_doc::Document::from_text("Before\nContents\nOld\t9\nAfter"));
let stop = |pos| TabStop { pos, align: TabAlign::Left, leader: TabLeader::None };
for (i, pos) in [(0, 96.0), (1, 84.0), (2, 24.0)] {
let p = s.doc.para_mut(StoryRef::Body, &Path::top(i)).unwrap();
p.props.style = Some("TOC2".into());
p.props.tabs = Some(vec![stop(pos)]);
}
s.doc
.para_mut(StoryRef::Body, &Path::top(1))
.unwrap()
.insert_object(
0,
InlineObject::Field { instr: "TOC".into(), result: String::new(), locked: false, dirty: false, code: false, form: String::new() },
&CharProps::default(),
)
.unwrap();
assert_eq!(cached_toc_tabs(&s), vec![("TOC2".into(), vec![stop(24.0)])]);
}
/// #1230: a selected picture or shape in a table cell gets its caption in the cell, under
/// (or over) the paragraph that holds it, not after the whole table; the number counts on.
#[test]
fn caption_for_an_object_in_a_cell_stays_in_the_cell() {
let mut s = Session::new(wordcraft_doc::Document::new());
s.run("insert.table", &json!({"rows": 1, "cols": 2})).unwrap();
s.run("insert.shape", &json!({"kind": "ellipse", "width": 60, "height": 40})).unwrap();
let (at, _) = crate::cmd::objects::selected(&s).expect("the shape is selected");
let (table, r, c) = at.path.cell().expect("the shape is in a cell");
s.run("references.caption", &json!({"label": "Figure", "text": "Kiln"})).unwrap();
let cell = |s: &Session| s.doc.table(StoryRef::Body, &table).unwrap().rows[r].cells[c].blocks.clone();
let texts: Vec<String> = cell(&s).iter().filter_map(|b| b.as_para()).map(|p| p.plain_text()).collect();
assert_eq!(texts.len(), 2, "{texts:?}");
assert_eq!(texts[1], "Figure 1: Kiln", "the caption is under the shape, in the cell");
assert_eq!(s.doc.body.iter().filter(|b| b.as_para().is_some_and(|p| p.props.style.as_deref() == Some("Caption"))).count(), 0);
// Above: over the shape's paragraph, and it is Figure 1 (the one below becomes 2).
let end = Pos { off: at.off + wordcraft_doc::para::OBJ.len_utf8(), ..at.clone() };
s.sel = Selection { anchor: at.clone(), focus: end };
assert!(crate::cmd::objects::object_selection(&s).is_some());
s.run("references.caption", &json!({"label": "Figure", "text": "Lid", "position": "above"})).unwrap();
let texts: Vec<String> = cell(&s).iter().filter_map(|b| b.as_para()).map(|p| p.plain_text()).collect();
assert_eq!(texts.len(), 3, "{texts:?}");
assert_eq!((texts[0].as_str(), texts[2].as_str()), ("Figure 1: Lid", "Figure 2: Kiln"));
// A picture outside a table: the caption goes right after its paragraph.
let mut s = Session::new(wordcraft_doc::Document::from_text("Before\nAfter"));
s.run("select.text", &json!({"text": "Before"})).unwrap();
s.run("caret.end", &json!({})).unwrap();
s.run("insert.shape", &json!({"kind": "ellipse", "width": 60, "height": 40})).unwrap();
s.run("references.caption", &json!({})).unwrap();
let texts: Vec<String> = s.doc.body.iter().filter_map(|b| b.as_para()).map(|p| p.plain_text()).collect();
assert_eq!(texts.get(1).map(String::as_str), Some("Figure 1"), "{texts:?}");
}
/// Output (PDF, print, page images) shows the body's unlocked SEQ, STYLEREF and REF fields with
/// their fresh results, as Word fills them in when it prints or exports even with "Update fields
/// before printing" off; NUMWORDS and a locked field keep their stored results there, and the
/// editor and the document keep every stored result.
#[test]
fn output_shows_fresh_seq_styleref_and_ref_results() {
let mut s = Session::new(wordcraft_doc::Document::from_text("Refs\nChapter One\nTarget words"));
s.run("select.text", &json!({"text": "Chapter One"})).unwrap();
s.run("styles.apply", &json!({"style": "Heading 1"})).unwrap();
s.run("select.text", &json!({"text": "Target words"})).unwrap();
s.run("insert.bookmark", &json!({"name": "Mark"})).unwrap();
s.run("caret.docEnd", &json!({})).unwrap();
for (instr, result) in [
(r#"SEQ Figure \* ARABIC"#, "7"),
(r#"STYLEREF "Heading 1""#, "stale style"),
("REF Mark \\h", "stale text"),
("NUMWORDS", "999"),
(r#"SEQ Table \* ARABIC"#, "9"),
] {
s.run("insert.field", &json!({"instr": instr, "result": result})).unwrap();
s.run("text.insert", &json!({"text": " "})).unwrap();
}
// Stale stored results (inserting a field computes its result), the last field locked.
let stored = ["7", "stale style", "stale text", "999", "9"];
let last = s.doc.para_paths(StoryRef::Body).pop().unwrap();
let para = s.doc.para_mut(StoryRef::Body, &last).unwrap();
let mut stale = stored.iter();
for o in &mut para.objects {
if let InlineObject::Field { result, locked, .. } = o {
*result = stale.next().unwrap().to_string();
*locked = stale.len() == 0;
}
}
para.touch();
let shown = |l: &wordcraft_layout::DocLayout| -> Vec<String> {
l.pages
.iter()
.flat_map(|pg| &pg.items)
.flat_map(|it| match it {
wordcraft_layout::Placed::Lines { para, .. } => para.shown.iter().map(|s| s.1.clone()).collect(),
_ => vec![],
})
.collect()
};
assert_eq!(shown(&s.export_layout()), ["1", "Chapter One", "Target words", "999", "9"]);
assert_eq!(shown(&s.layout()), stored, "the editor shows the stored results");
assert_eq!(fields(&s).iter().map(|f| f.1.as_str()).collect::<Vec<_>>(), stored, "the document keeps them");
let pdf = s.print_pdf(&json!({})).unwrap().0;
assert!(pdf.len() > 1000);
// Once the fields are current, output copies nothing.
update_seq(&mut s).unwrap();
update_fields(&mut s).unwrap();
assert!(output_fields(&s.doc).is_none());
}
#[test]
fn output_numbers_text_box_captions_where_their_boxes_are_anchored() {
// Measured in Word for Windows, every SEQ result stored as a stale 9: output numbers a text
// box's SEQ field where the box is anchored (box B before C in its paragraph, box F after E),
// and a REF to a bookmark in a box shows the box's text as stored.
let ns = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:v="urn:schemas-microsoft-com:vml""#;
let run = |t: &str| format!(r#"<w:r><w:t xml:space="preserve">{t}</w:t></w:r>"#);
let field = |instr: &str, stored: &str| {
format!(
r#"<w:r><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:instrText xml:space="preserve"> {instr} </w:instrText></w:r><w:r><w:fldChar w:fldCharType="separate"/></w:r>{}<w:r><w:fldChar w:fldCharType="end"/></w:r>"#,
run(stored)
)
};
let seq = |label: &str| format!("{}{}", run(&format!("{label} Fig ")), field("SEQ Fig \\* ARABIC", "9"));
let text_box = |inner: String| {
format!(
r##"<w:r><w:pict><v:shape type="#_x0000_t202" style="position:absolute;margin-left:300pt;width:150pt;height:30pt"><v:textbox><w:txbxContent><w:p>{inner}</w:p></w:txbxContent></v:textbox></v:shape></w:pict></w:r>"##
)
};
let marked = format!(r#"<w:bookmarkStart w:id="1" w:name="boxmark"/>{}<w:bookmarkEnd w:id="1"/>"#, seq("B"));
let paras = [
seq("A"),
format!("{}{}{}", run("anchor first: "), text_box(marked), seq("C")),
seq("D"),
format!("{}{}", seq("E"), text_box(seq("F"))),
seq("G"),
format!("{}{}", run("ref: "), field("REF boxmark \\h", "stored")),
];
let body: String = paras.iter().map(|p| format!("<w:p>{p}</w:p>")).collect();
let document = format!("<w:document {ns}><w:body>{body}<w:sectPr/></w:body></w:document>");
let root = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
for (name, text) in [("_rels/.rels", root), ("word/document.xml", document.as_str())] {
zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
std::io::Write::write_all(&mut zw, text.as_bytes()).unwrap();
}
let s = Session::new(crate::io::open_bytes("boxes.docx", &zw.finish().unwrap().into_inner()).unwrap());
let out = crate::cmd::references::output_fields(&s.doc).expect("fresh numbers");
// The SEQ and REF results in reading order, text boxes where they are anchored.
let mut results = Vec::new();
out.objects_in_reading_order(&mut |o| {
if let InlineObject::Field { instr, result, .. } = o {
results.push((instr.split_whitespace().next().unwrap_or("").to_string(), result.clone()));
}
});
let seqs: Vec<&str> = results.iter().filter(|(n, _)| n == "SEQ").map(|(_, r)| r.as_str()).collect();
assert_eq!(seqs, ["1", "2", "3", "4", "5", "6", "7"], "{results:?}");
let refs: Vec<&str> = results.iter().filter(|(n, _)| n == "REF").map(|(_, r)| r.as_str()).collect();
assert_eq!(refs, ["B Fig 9"], "{results:?}");
}
/// Printed or exported, a body REF to a bookmark in a header, footer, footnote, endnote or
/// comment shows the bookmarked text, as Word's output does (measured in Word for Windows, each
/// REF stored as "stale"), rather than Word's missing-bookmark error.
#[test]
fn output_shows_references_to_bookmarks_outside_the_body() {
let ns = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships""#;
let mark = |name: &str, id: u32| {
format!(r#"<w:bookmarkStart w:id="{id}" w:name="{name}"/><w:r><w:t>Target {name}</w:t></w:r><w:bookmarkEnd w:id="{id}"/>"#)
};
let field = |name: &str| {
format!(
r#"<w:p><w:r><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:instrText xml:space="preserve"> REF {name} \h </w:instrText></w:r><w:r><w:fldChar w:fldCharType="separate"/></w:r><w:r><w:t>stale</w:t></w:r><w:r><w:fldChar w:fldCharType="end"/></w:r></w:p>"#
)
};
let names = ["HDR", "FTR", "FN", "EN", "CMT"];
let refs: String = names.iter().map(|n| field(n)).collect();
let document = format!(
r#"<w:document {ns}><w:body>{refs}<w:p><w:r><w:t>Body </w:t></w:r><w:r><w:footnoteReference w:id="1"/></w:r><w:r><w:endnoteReference w:id="1"/></w:r><w:commentRangeStart w:id="0"/><w:r><w:t>commented</w:t></w:r><w:commentRangeEnd w:id="0"/><w:r><w:commentReference w:id="0"/></w:r></w:p><w:sectPr><w:headerReference w:type="default" r:id="rIdH"/><w:footerReference w:type="default" r:id="rIdF"/></w:sectPr></w:body></w:document>"#
);
let header = format!(r#"<w:hdr {ns}><w:p>{}</w:p></w:hdr>"#, mark("HDR", 1));
let footer = format!(r#"<w:ftr {ns}><w:p>{}</w:p></w:ftr>"#, mark("FTR", 2));
let footnotes =
format!(r#"<w:footnotes {ns}><w:footnote w:id="1"><w:p><w:r><w:footnoteRef/></w:r>{}</w:p></w:footnote></w:footnotes>"#, mark("FN", 3));
let endnotes = format!(r#"<w:endnotes {ns}><w:endnote w:id="1"><w:p><w:r><w:endnoteRef/></w:r>{}</w:p></w:endnote></w:endnotes>"#, mark("EN", 4));
let comments = format!(r#"<w:comments {ns}><w:comment w:id="0" w:author="probe"><w:p>{}</w:p></w:comment></w:comments>"#, mark("CMT", 5));
let rel = "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
let rels = format!(
r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rIdH" Type="{rel}/header" Target="header1.xml"/><Relationship Id="rIdF" Type="{rel}/footer" Target="footer1.xml"/><Relationship Id="rIdN" Type="{rel}/footnotes" Target="footnotes.xml"/><Relationship Id="rIdE" Type="{rel}/endnotes" Target="endnotes.xml"/><Relationship Id="rIdC" Type="{rel}/comments" Target="comments.xml"/></Relationships>"#
);
let root = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
let files = [
("_rels/.rels", root.to_string()),
("word/_rels/document.xml.rels", rels),
("word/document.xml", document),
("word/header1.xml", header),
("word/footer1.xml", footer),
("word/footnotes.xml", footnotes),
("word/endnotes.xml", endnotes),
("word/comments.xml", comments),
];
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
for (name, text) in &files {
zw.start_file(*name, zip::write::SimpleFileOptions::default()).unwrap();
std::io::Write::write_all(&mut zw, text.as_bytes()).unwrap();
}
let s = Session::new(crate::io::open_bytes("stories.docx", &zw.finish().unwrap().into_inner()).unwrap());
let out = crate::cmd::references::output_fields(&s.doc).expect("fresh references");
let mut results = Vec::new();
for path in out.para_paths(StoryRef::Body) {
for o in &out.para(StoryRef::Body, &path).unwrap().objects {
if let InlineObject::Field { result, .. } = o {
results.push(result.clone());
}
}
}
let expected: Vec<String> = names.iter().map(|n| format!("Target {n}")).collect();
assert_eq!(results, expected);
}
/// Printed or exported, a REF to a bookmark the document doesn't have shows Word's error
/// (measured in Word for Windows: "Error! Reference source not found."); the editor and the
/// document keep the stored result, and a REF showing "above" or "below" (`\p`) keeps it too.
#[test]
fn output_shows_words_error_for_a_reference_to_a_missing_bookmark() {
let mut s = Session::new(wordcraft_doc::Document::from_text("See "));
s.run("caret.docEnd", &json!({})).unwrap();
for instr in ["REF NoSuchMark \\h", "REF NoSuchMark \\p"] {
s.run("insert.field", &json!({"instr": instr, "result": "x"})).unwrap();
s.run("text.insert", &json!({"text": " "})).unwrap();
}
let para = s.doc.para_mut(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap();
for o in &mut para.objects {
if let InlineObject::Field { result, .. } = o {
*result = "stale".into();
}
}
para.touch();
let shown = |l: &wordcraft_layout::DocLayout| -> Vec<String> {
l.pages[0]
.items
.iter()
.flat_map(|it| match it {
wordcraft_layout::Placed::Lines { para, .. } => para.shown.iter().map(|s| s.1.clone()).collect(),
_ => vec![],
})
.collect()
};
assert_eq!(shown(&s.export_layout()), [REFERENCE_NOT_FOUND, "stale"]);
assert_eq!(shown(&s.layout()), ["stale", "stale"], "the editor shows the stored result");
assert_eq!(fields(&s).iter().map(|f| f.1.as_str()).collect::<Vec<_>>(), ["stale", "stale"], "the document keeps it");
}
/// A REF showing the bookmarked paragraph's number (`\r`, `\n`, `\w`), which output doesn't
/// compute, keeps its stored number when printed or exported rather than show the paragraph's
/// text; a plain REF to the same bookmark is filled in.
#[test]
fn output_keeps_paragraph_number_references_as_stored() {
let mut s = Session::new(wordcraft_doc::Document::from_text("Target heading\nRefs "));
s.run("select.text", &json!({"text": "Target heading"})).unwrap();
s.run("insert.bookmark", &json!({"name": "Mark"})).unwrap();
s.run("caret.docEnd", &json!({})).unwrap();
let instrs = ["REF Mark \\r \\h", "REF Mark \\n", "REF Mark \\w \\h", "REF Mark \\h"];
for instr in instrs {
s.run("insert.field", &json!({"instr": instr, "result": "x"})).unwrap();
s.run("text.insert", &json!({"text": " "})).unwrap();
}
let last = s.doc.para_paths(StoryRef::Body).pop().unwrap();
let para = s.doc.para_mut(StoryRef::Body, &last).unwrap();
for o in &mut para.objects {
if let InlineObject::Field { result, .. } = o {
*result = "2.1".into();
}
}
para.touch();
let out = output_fields(&s.doc).expect("the plain REF changes");
let results: Vec<String> = out
.para(StoryRef::Body, &last)
.unwrap()
.objects
.iter()
.filter_map(|o| match o {
InlineObject::Field { result, .. } => Some(result.clone()),
_ => None,
})
.collect();
assert_eq!(results, ["2.1", "2.1", "2.1", "Target heading"]);
}
/// The results of the fields in the paragraph at `path` of `d`.
fn results_in(d: &wordcraft_doc::Document, path: &wordcraft_doc::Path) -> Vec<String> {
d.para(StoryRef::Body, path)
.unwrap()
.objects
.iter()
.filter_map(|o| match o {
InlineObject::Field { result, .. } => Some(result.clone()),
_ => None,
})
.collect()
}
/// Set every field result in the last body paragraph of `s` to `stale`; that paragraph's path.
fn stale_last_paragraph(s: &mut Session, stale: &str) -> wordcraft_doc::Path {
let last = s.doc.para_paths(StoryRef::Body).pop().unwrap();
let para = s.doc.para_mut(StoryRef::Body, &last).unwrap();
for o in &mut para.objects {
if let InlineObject::Field { result, .. } = o {
*result = stale.into();
}
}
para.touch();
last
}
/// Word shows a REF to a bookmark that ends in a later paragraph as paragraphs of the result
/// (measured in Word for Windows: "one" and "Beta" on two lines for a bookmark from "one" to
/// "Beta"), which a field's one-paragraph result can't hold: output keeps the stored result
/// rather than show the first paragraph's part alone.
#[test]
fn output_keeps_a_reference_to_a_bookmark_over_paragraphs_as_stored() {
let mut s = Session::new(wordcraft_doc::Document::from_text("Alpha one\nBeta two\nShort mark\nRefs "));
let at = |block: usize, off: usize| wordcraft_doc::Pos { story: StoryRef::Body, path: wordcraft_doc::Path::top(block), off };
s.sel = crate::Selection { anchor: at(0, 6), focus: at(1, 4) };
s.run("insert.bookmark", &json!({"name": "Span"})).unwrap();
s.run("select.text", &json!({"text": "Short mark"})).unwrap();
s.run("insert.bookmark", &json!({"name": "Short"})).unwrap();
s.run("caret.docEnd", &json!({})).unwrap();
for instr in ["REF Span \\h", "REF Short \\h"] {
s.run("insert.field", &json!({"instr": instr, "result": "x"})).unwrap();
s.run("text.insert", &json!({"text": " "})).unwrap();
}
let last = stale_last_paragraph(&mut s, "stored");
let out = output_fields(&s.doc).expect("the short bookmark's REF changes");
assert_eq!(results_in(&out, &last), ["stored", "Short mark"]);
}
/// A STYLEREF to a heading holding a SEQ field shows the heading with the SEQ field's new number,
/// in output and after F9: the numbers are filled in before the style references read them.
#[test]
fn a_style_reference_reads_the_headings_new_sequence_number() {
let mut s = Session::new(wordcraft_doc::Document::from_text("Chapter \nBody\nRefs "));
s.run("select.text", &json!({"text": "Chapter "})).unwrap();
s.run("styles.apply", &json!({"style": "Heading 1"})).unwrap();
s.sel = crate::Selection::caret(wordcraft_doc::Pos { story: StoryRef::Body, path: wordcraft_doc::Path::top(0), off: 8 });
s.run("insert.field", &json!({"instr": "SEQ Chapter \\* ARABIC", "result": "x"})).unwrap();
s.run("caret.docEnd", &json!({})).unwrap();
s.run("insert.field", &json!({"instr": "STYLEREF \"Heading 1\"", "result": "x"})).unwrap();
// Stale stored results: the heading's number 9, the reference "stale".
let heading = wordcraft_doc::Path::top(0);
let para = s.doc.para_mut(StoryRef::Body, &heading).unwrap();
for o in &mut para.objects {
if let InlineObject::Field { result, .. } = o {
*result = "9".into();
}
}
para.touch();
let last = stale_last_paragraph(&mut s, "stale");
let out = output_fields(&s.doc).expect("the fields change");
assert_eq!(results_in(&out, &heading), ["1"]);
assert_eq!(results_in(&out, &last), ["Chapter 1"]);
update_seq(&mut s).unwrap();
assert_eq!(results_in(&s.doc, &last), ["Chapter 1"], "F9's renumbering too");
}
/// Output looks the bookmarks up once for all of a document's REF fields: several fields to one
/// bookmark, fields to others, and one to a bookmark the document lacks each show what
/// `bookmark_text` (F9's lookup) gives, or Word's error.
#[test]
fn output_gives_every_reference_its_bookmarks_text() {
let mut s = Session::new(wordcraft_doc::Document::from_text("First target\nSecond target\nRefs "));
for (text, name) in [("First target", "A"), ("Second target", "B")] {
s.run("select.text", &json!({"text": text})).unwrap();
s.run("insert.bookmark", &json!({"name": name})).unwrap();
}
s.run("caret.docEnd", &json!({})).unwrap();
let targets = ["A", "B", "A", "Missing", "A"];
for t in targets {
s.run("insert.field", &json!({"instr": format!("REF {t} \\h"), "result": "x"})).unwrap();
s.run("text.insert", &json!({"text": " "})).unwrap();
}
let last = s.doc.para_paths(StoryRef::Body).pop().unwrap();
let para = s.doc.para_mut(StoryRef::Body, &last).unwrap();
for o in &mut para.objects {
if let InlineObject::Field { result, .. } = o {
*result = "stale".into();
}
}
para.touch();
let out = output_fields(&s.doc).expect("the stale results change");
let results: Vec<String> = out
.para(StoryRef::Body, &last)
.unwrap()
.objects
.iter()
.filter_map(|o| match o {
InlineObject::Field { result, .. } => Some(result.clone()),
_ => None,
})
.collect();
let expected: Vec<String> = targets.iter().map(|t| super::bookmark_text(&s.doc, t).unwrap_or_else(|| REFERENCE_NOT_FOUND.to_string())).collect();
assert_eq!(results, expected);
assert_eq!(results, ["First target", "Second target", "First target", REFERENCE_NOT_FOUND, "First target"]);
}