Files
wordcraft/crates/engine/tests/date_fields.rs
T
Hamza NaveedandBrandon Thomas ffacc4c08c Fields: refresh unlocked DATE and TIME when a document is opened (#1679)
A `DATE` or `TIME` field showed whatever was cached the last time the file was
saved, so a letter written from a template went out carrying the date the
template was saved. Word refreshes those two whenever it shows, prints or
exports a document; WordCraft only did so on an explicit Update Field(s), on
insert, or when printing with "Update fields before printing" on. Headless
`convert` to PDF was the worst of it: nothing hints the date is old.

Refresh them where a document is read instead, in a new `io::opened` beside
`ensure_nonempty` -- the two things a freshly-read document needs -- so `text`,
PDF, rendering and the window all show the same, current value. The model's
stored result is the one source they share; patching each output path would
have needed a clock in two places and still missed the next one.

Narrow on purpose. Only `DATE` and `TIME`: `CREATEDATE`, `SAVEDATE` and
`PRINTDATE` record when something happened and are not "now", and `REF`, `TOC`
and citations stay on explicit update, as in Word. A field carrying `w:fldLock`
keeps its stored result, which is what the lock is for. Headers, footers and
text boxes are covered too, not only the body.

The field code is untouched, so saving writes the field back as it was, with a
current cached result -- as Word writes one.

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-15 01:07:07 -04:00

132 lines
6.7 KiB
Rust

//! Unlocked `DATE` and `TIME` fields are brought up to now when a document is opened, as Word
//! does whenever it shows, prints or exports one (#1672). Packages are hand-written: the writer
//! would never produce the stale cached result this is about.
use std::io::Write;
use wordcraft_doc::{InlineObject, StoryRef};
fn zip(entries: &[(&str, &str)]) -> Vec<u8> {
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
for (name, body) in entries {
zw.start_file(*name, zip::write::SimpleFileOptions::default()).unwrap();
zw.write_all(body.as_bytes()).unwrap();
}
zw.finish().unwrap().into_inner()
}
/// A field run: its code, its cached result, and whether it carries `w:fldLock`.
fn field(instr: &str, cached: &str, locked: bool) -> String {
let lock = if locked { r#" w:fldLock="1""# } else { "" };
format!(
concat!(
r#"<w:r><w:fldChar w:fldCharType="begin"{}/></w:r>"#,
r#"<w:r><w:instrText xml:space="preserve"> {} </w:instrText></w:r>"#,
r#"<w:r><w:fldChar w:fldCharType="separate"/></w:r>"#,
r#"<w:r><w:t xml:space="preserve">{}</w:t></w:r>"#,
r#"<w:r><w:fldChar w:fldCharType="end"/></w:r>"#,
),
lock,
instr.replace('"', "&quot;"),
cached
)
}
fn open_body(body: &str) -> wordcraft_doc::Document {
let rels = r#"<?xml version="1.0" encoding="UTF-8"?><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 doc = format!(
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><w:body>{body}</w:body></w:document>"#
);
wordcraft_engine::io::open_bytes("d.docx", &zip(&[("_rels/.rels", rels), ("word/document.xml", &doc)])).expect("opens")
}
/// Every field of the body, as (code, result).
fn fields(d: &wordcraft_doc::Document) -> Vec<(String, String)> {
let mut out = Vec::new();
for path in d.para_paths(StoryRef::Body) {
let Some(p) = d.para(StoryRef::Body, &path) else { continue };
for o in &p.objects {
if let InlineObject::Field { instr, result, .. } = o {
out.push((instr.clone(), result.clone()));
}
}
}
out
}
#[test]
fn unlocked_date_and_time_fields_are_refreshed_on_open() {
// The reported fixture: both cached in January 2020.
let d = open_body(&format!(
"<w:p>{}</w:p><w:p>{}</w:p>",
field(r#"DATE \@ "yyyy-MM-dd""#, "2020-01-01", false),
field(r#"TIME \@ "dddd, d MMMM yyyy""#, "Wednesday, 1 January 2020", false),
));
let got = fields(&d);
assert_eq!(got.len(), 2);
// Compared against the same clock the refresh used, so the test does not age.
assert_eq!(got[0].1, wordcraft_engine::cmd::insert::format_date("yyyy-MM-dd"), "DATE is today");
assert_eq!(got[1].1, wordcraft_engine::cmd::insert::format_date("dddd, d MMMM yyyy"), "TIME is today");
assert_ne!(got[0].1, "2020-01-01", "not the stored result");
// The code itself is untouched, so a save writes the field back as it was.
assert_eq!(got[0].0, r#"DATE \@ "yyyy-MM-dd""#);
}
#[test]
fn a_date_field_with_no_picture_uses_words_default() {
let d = open_body(&format!("<w:p>{}</w:p><w:p>{}</w:p>", field("DATE", "2020-01-01", false), field("TIME", "00:00", false)));
let got = fields(&d);
assert_eq!(got[0].1, wordcraft_engine::cmd::insert::format_date("M/d/yyyy"));
assert_eq!(got[1].1, wordcraft_engine::cmd::insert::format_date("h:mm am/pm"));
}
#[test]
fn a_locked_date_field_keeps_its_stored_result() {
// `w:fldLock` is exactly the instruction not to refresh.
let d = open_body(&format!("<w:p>{}</w:p>", field(r#"DATE \@ "yyyy-MM-dd""#, "2020-01-01", true)));
assert_eq!(fields(&d)[0].1, "2020-01-01");
}
#[test]
fn other_date_fields_keep_their_stored_result() {
// These record when something happened; they are not "now", and Word does not refresh them
// on open either. Nor is any other field touched.
let d = open_body(&format!(
"<w:p>{}{}{}{}</w:p>",
field(r#"CREATEDATE \@ "yyyy-MM-dd""#, "2019-05-05", false),
field(r#"SAVEDATE \@ "yyyy-MM-dd""#, "2019-06-06", false),
field(r#"PRINTDATE \@ "yyyy-MM-dd""#, "2019-07-07", false),
field("AUTHOR", "Ada Lovelace", false),
));
let results: Vec<String> = fields(&d).into_iter().map(|(_, r)| r).collect();
assert_eq!(results, ["2019-05-05", "2019-06-06", "2019-07-07", "Ada Lovelace"]);
}
#[test]
fn a_date_field_in_a_header_is_refreshed_too() {
// A letter template usually carries its date in the header, which is its own story.
let rels = r#"<?xml version="1.0" encoding="UTF-8"?><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 drels = r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rH1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/header" Target="header1.xml"/></Relationships>"#;
let header = format!(
r#"<?xml version="1.0" encoding="UTF-8"?><w:hdr xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:p>{}</w:p></w:hdr>"#,
field(r#"DATE \@ "yyyy-MM-dd""#, "2020-01-01", false)
);
let doc = r#"<?xml version="1.0" encoding="UTF-8"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><w:body><w:p><w:r><w:t>Body</w:t></w:r></w:p><w:sectPr><w:headerReference w:type="default" r:id="rH1"/></w:sectPr></w:body></w:document>"#;
let d = wordcraft_engine::io::open_bytes(
"d.docx",
&zip(&[("_rels/.rels", rels), ("word/document.xml", doc), ("word/_rels/document.xml.rels", drels), ("word/header1.xml", &header)]),
)
.expect("opens");
let today = wordcraft_engine::cmd::insert::format_date("yyyy-MM-dd");
let found: Vec<String> = d
.parts
.values()
.flat_map(|p| p.blocks.iter().filter_map(|b| b.as_para()).flat_map(|p| p.objects.clone()))
.filter_map(|o| match o {
InlineObject::Field { result, .. } => Some(result),
_ => None,
})
.collect();
assert_eq!(found, [today], "the header's field is refreshed as well as the body's");
}