Files
designcraft/crates/engine/src/cmd/datamerge/tests.rs
T

1151 lines
55 KiB
Rust

//! Engine tests for data merge. They drive the commands, not a private copy of the parser.
use std::path::{Path, PathBuf};
use designcraft_doc::{Content, DataField, DataFieldKind, DataSource, Delimiter, HyperlinkDest, SourceStatus};
use serde_json::{Value, json};
use super::parse::{self, parse_csv};
use super::{read_path_capped, relative_between, resolve_sources_on_open};
use crate::Session;
fn scratch(name: &str) -> PathBuf {
let p = std::env::temp_dir().join(format!("dc-merge-{}-{name}", std::process::id()));
let _ = std::fs::remove_dir_all(&p);
std::fs::create_dir_all(&p).unwrap();
p
}
fn write(path: &Path, body: &str) {
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir).unwrap();
}
std::fs::write(path, body).unwrap();
}
const PNG_B64: &str = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==";
fn write_png(path: &Path) {
let bytes = crate::cmd::base64_decode(PNG_B64);
assert!(designcraft_render::image_size(&bytes).is_some(), "the fixture png must decode");
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir).unwrap();
}
std::fs::write(path, bytes).unwrap();
}
fn exec(s: &mut Session, id: &str, p: Value) -> Value {
s.execute(id, &p).unwrap_or_else(|e| panic!("{id}: {e}"))
}
fn err(s: &mut Session, id: &str, p: Value) -> String {
s.execute(id, &p).unwrap_err().to_string()
}
fn texts(s: &Session) -> Vec<String> {
let mut v: Vec<String> = s.documents()[s.active_index().unwrap()].doc.stories.values().map(|st| st.text.clone()).collect();
v.sort();
v
}
fn origins(s: &Session) -> Vec<(i32, i32)> {
let doc = &s.documents()[s.active_index().unwrap()].doc;
let mut v = Vec::new();
for sp in &doc.spreads {
for it in &sp.items {
let b = it.bounds();
v.push((b.y0.round() as i32, b.x0.round() as i32));
}
}
v.sort();
v
}
fn new_doc(s: &mut Session, pages: u32) {
exec(s, "file.new", json!({"facingPages": false, "pages": pages}));
}
fn text_frame(s: &mut Session, rect: [f64; 4], text: &str, spread: Option<u32>) -> Value {
let mut p = json!({"rect": rect, "content": "text", "text": text});
if let Some(spread) = spread {
p["spread"] = json!(spread);
}
exec(s, "frame.create", p)
}
fn select_csv(s: &mut Session, body: &str) -> Value {
exec(s, "data.source.select", json!({"bytes": crate::cmd::base64_encode(body.as_bytes()), "name": "people.csv"}))
}
#[test]
fn csv_parsing() {
let r = parse_csv("Name,Title\n\"Ada, Countess\",\"Analyst \"\"No. 1\"\"\"\r\nGrace,Admiral\n");
assert_eq!(r, vec![vec!["Name", "Title"], vec!["Ada, Countess", "Analyst \"No. 1\""], vec!["Grace", "Admiral"]]);
let quoted = parse_csv("Name\n\"Ada\nGrace\"\n");
assert_eq!(quoted[1][0], "Ada\nGrace");
}
#[test]
fn data_source_sniffs_quoted_logical_headers() {
for (body, delimiter, names) in [
("\"Last, First\";City\nAda;London\n", Delimiter::Semicolon, ["Last, First", "City"]),
("\"Name\tLabel\",City\nAda,London\n", Delimiter::Comma, ["Name\tLabel", "City"]),
("\"Name\nLabel\";City\nAda;London\n", Delimiter::Semicolon, ["Name\nLabel", "City"]),
("\r\n \r\nName;City\r\nAda;London\r\n", Delimiter::Semicolon, ["Name", "City"]),
("\"Name \"\"label,\"\"\";City\nAda;London\n", Delimiter::Semicolon, ["Name \"label,\"", "City"]),
] {
let mut s = Session::new();
new_doc(&mut s, 1);
let result = exec(&mut s, "data.source.select", json!({"bytes": crate::cmd::base64_encode(body.as_bytes()), "name": "people.txt"}));
assert_eq!(result["records"], 1, "{body:?}");
let source = &s.documents()[0].doc.data_merge.sources[0];
assert_eq!(source.delimiter, delimiter, "{body:?}");
assert_eq!(source.fields.iter().map(|f| f.name.as_str()).collect::<Vec<_>>(), names, "{body:?}");
assert_eq!(source.rows, vec![vec!["Ada", "London"]], "{body:?}");
}
}
#[test]
fn data_source_sniff_quote_state_belongs_to_the_candidate_delimiter() {
for ending in ["\n", "\r\n", "\r"] {
for (body, delimiter, names) in [
(format!("Name,\"preferred\tCity\"{ending}Ada\tLondon{ending}"), Delimiter::Tab, ["Name,\"preferred", "City\""]),
(format!("\"Last, First\";City{ending}Ada;London{ending}"), Delimiter::Semicolon, ["Last, First", "City"]),
(format!("\"Name\tLabel\",City{ending}Ada,London{ending}"), Delimiter::Comma, ["Name\tLabel", "City"]),
(format!("\"\";\"\"{ending} \t{ending}\"Last, First\";City{ending}Ada;London{ending}"), Delimiter::Semicolon, ["Last, First", "City"]),
] {
let automatic = parse::table_from_bytes(body.as_bytes(), "people.txt", None).unwrap();
let explicit = parse::table_from_bytes(body.as_bytes(), "people.txt", Some(delimiter)).unwrap();
let fields: Vec<_> = names.into_iter().map(|name| DataField { name: name.into(), kind: DataFieldKind::Text }).collect();
assert_eq!(automatic.delimiter, delimiter, "{body:?}");
assert_eq!(automatic.fields, fields, "{body:?}");
assert_eq!(automatic.rows, vec![vec!["Ada", "London"]], "{body:?}");
assert!(automatic.warnings.is_empty(), "{body:?}: {:?}", automatic.warnings);
assert_eq!(automatic, explicit, "{body:?}");
}
}
}
#[test]
fn data_source_sniff_prefers_coherent_quoted_field_boundaries() {
for ending in ["\n", "\r\n", "\r"] {
for (header, first, second, delimiter, names, rows, warnings) in [
(
"\"Name\";\"Last, First\"",
"\"Ada\";\"Lovelace, Countess\"",
"\"Grace\";\"Hopper, Admiral\"",
Delimiter::Semicolon,
vec!["Name", "Last, First"],
vec![vec!["Ada", "Lovelace, Countess"], vec!["Grace", "Hopper, Admiral"]],
vec![],
),
(
"\"Name\",\"Region\tCity\"",
"\"Ada\",\"London\"",
"\"Grace\",\"Paris\"",
Delimiter::Comma,
vec!["Name", "Region\tCity"],
vec![vec!["Ada", "London"], vec!["Grace", "Paris"]],
vec![],
),
(
"\"Name\";\"City\";\"Last, \"\"First\"\"\";\"Title\"",
"\"Ada\";\"London\";\"Lovelace, \"\"Countess\"\"\";\"Analyst\"",
"\"Grace\";\"Paris\";\"Hopper, \"\"Admiral\"\"\";\"Engineer\";\"ignored\"",
Delimiter::Semicolon,
vec!["Name", "City", "Last, \"First\"", "Title"],
vec![vec!["Ada", "London", "Lovelace, \"Countess\"", "Analyst"], vec!["Grace", "Paris", "Hopper, \"Admiral\"", "Engineer"]],
vec!["Row 3 has cells past the header. Those cells were ignored."],
),
(
"\"Name\",\"Role\",\"Region\t\"\"City\"\"\",\"Title\"",
"\"Ada\",\"Analyst\",\"London\t\"\"West End\"\"\",\"Countess\"",
"\"Grace\",\"Engineer\",\"Paris\t\"\"Centre\"\"\",\"Admiral\",\"ignored\"",
Delimiter::Comma,
vec!["Name", "Role", "Region\t\"City\"", "Title"],
vec![vec!["Ada", "Analyst", "London\t\"West End\"", "Countess"], vec!["Grace", "Engineer", "Paris\t\"Centre\"", "Admiral"]],
vec!["Row 3 has cells past the header. Those cells were ignored."],
),
] {
let body = format!("{header}{ending}{first}{ending}{second}{ending}");
let automatic = parse::table_from_bytes(body.as_bytes(), "people.txt", None).unwrap();
let explicit = parse::table_from_bytes(body.as_bytes(), "people.txt", Some(delimiter)).unwrap();
let fields: Vec<_> = names.into_iter().map(|name| DataField { name: name.into(), kind: DataFieldKind::Text }).collect();
assert_eq!(automatic.delimiter, delimiter, "{body:?}");
assert_eq!(automatic.fields, fields, "{body:?}");
assert_eq!(automatic.rows, rows, "{body:?}");
assert_eq!(automatic.warnings, warnings, "{body:?}");
assert_eq!(automatic, explicit, "{body:?}");
}
}
}
#[test]
fn data_source_sniff_retains_priority_and_permissive_fallback() {
for ending in ["\n", "\r\n", "\r"] {
for (header, row, delimiter, names, cells) in [
// Equally coherent candidates retain Tab, then Comma, then Semicolon priority.
("Name,Title\tCity", "Ada,Analyst\tLondon\tignored", Delimiter::Tab, ["Name,Title", "City"], ["Ada,Analyst", "London"]),
("Name;Title,City", "Ada;Analyst,London,ignored", Delimiter::Comma, ["Name;Title", "City"], ["Ada;Analyst", "London"]),
// No coherent candidate separates fields here. Keep parsing the trailing text
// after a quoted field exactly as before, using the same delimiter priority.
(
"\"Name\"suffix,Title\tCity",
"\"Ada\"suffix,Analyst\tLondon\tignored",
Delimiter::Tab,
["Namesuffix,Title", "City"],
["Adasuffix,Analyst", "London"],
),
(
"\"Name\"suffix;Title,City",
"\"Ada\"suffix;Analyst,London,ignored",
Delimiter::Comma,
["Namesuffix;Title", "City"],
["Adasuffix;Analyst", "London"],
),
("\"Name\"suffix;City", "\"Ada\"suffix;London;ignored", Delimiter::Semicolon, ["Namesuffix", "City"], ["Adasuffix", "London"]),
] {
let body = format!("{header}{ending}{row}{ending}");
let automatic = parse::table_from_bytes(body.as_bytes(), "people.txt", None).unwrap();
let explicit = parse::table_from_bytes(body.as_bytes(), "people.txt", Some(delimiter)).unwrap();
let fields: Vec<_> = names.into_iter().map(|name| DataField { name: name.into(), kind: DataFieldKind::Text }).collect();
assert_eq!(automatic.delimiter, delimiter, "{body:?}");
assert_eq!(automatic.fields, fields, "{body:?}");
assert_eq!(automatic.rows, vec![cells.to_vec()], "{body:?}");
assert_eq!(automatic.warnings, vec!["Row 2 has cells past the header. Those cells were ignored."], "{body:?}");
assert_eq!(automatic, explicit, "{body:?}");
}
}
}
#[test]
fn data_source_sniff_allows_post_quote_whitespace() {
for ending in ["\n", "\r\n", "\r"] {
for space in [" ", "\u{00A0}", "\u{2003}"] {
for (header, separator, delimiter, names) in [
(format!("\"Name\"{space};\"Last, First\""), ';', Delimiter::Semicolon, ["Name", "Last, First"]),
(format!("\"Name\"{space},\"Region\tCity\"{space}"), ',', Delimiter::Comma, ["Name", "Region\tCity"]),
(format!("\"Name\"{space}\t\"Last, First; City\"{space}"), '\t', Delimiter::Tab, ["Name", "Last, First; City"]),
] {
let body = format!(
"{header}{ending}\"Ada\"{space}{separator}\"London\"{space}{ending}\"Grace\"{space}{separator}\"Paris\"{space}{separator}ignored{ending}"
);
let automatic = parse::table_from_bytes(body.as_bytes(), "people.txt", None).unwrap();
let explicit = parse::table_from_bytes(body.as_bytes(), "people.txt", Some(delimiter)).unwrap();
let fields: Vec<_> = names.into_iter().map(|name| DataField { name: name.into(), kind: DataFieldKind::Text }).collect();
assert_eq!(automatic.delimiter, delimiter, "{body:?}");
assert_eq!(automatic.fields, fields, "{body:?}");
// Header classification trims; record values must keep their whitespace.
assert_eq!(
automatic.rows,
vec![vec![format!("Ada{space}"), format!("London{space}")], vec![format!("Grace{space}"), format!("Paris{space}")]],
"{body:?}"
);
assert_eq!(automatic.warnings, vec!["Row 3 has cells past the header. Those cells were ignored."], "{body:?}");
assert_eq!(automatic, explicit, "{body:?}");
}
}
}
}
#[test]
fn data_source_sniff_post_quote_whitespace_keeps_closed_field_state() {
for ending in ["\n", "\r\n", "\r"] {
for space in [" ", "\u{00A0}", "\u{2003}"] {
for suffix in ["suffix", "\"suffix\""] {
let body = format!("\"Name\"{space}{suffix};\"Last, First\"{ending}Ada,London,ignored{ending}");
// Both separated formats have incompatible post-quote suffixes. Keep
// the permissive Comma fallback, including literal quotes in the name.
let automatic = parse::table_from_bytes(body.as_bytes(), "people.txt", None).unwrap();
let explicit = parse::table_from_bytes(body.as_bytes(), "people.txt", Some(Delimiter::Comma)).unwrap();
assert_eq!(automatic.delimiter, Delimiter::Comma, "{body:?}");
assert_eq!(
automatic.fields,
vec![
DataField { name: format!("Name{space}{suffix};\"Last"), kind: DataFieldKind::Text },
DataField { name: "First\"".into(), kind: DataFieldKind::Text },
],
"{body:?}"
);
assert_eq!(automatic.rows, vec![vec!["Ada", "London"]], "{body:?}");
assert_eq!(automatic.warnings, vec!["Row 2 has cells past the header. Those cells were ignored."], "{body:?}");
assert_eq!(automatic, explicit, "{body:?}");
}
}
}
}
#[test]
fn data_source_literal_quote_header_merges_the_detected_column() {
let mut s = Session::new();
new_doc(&mut s, 1);
text_frame(&mut s, [36.0, 36.0, 240.0, 80.0], "", None);
let body = "Name,\"preferred\tCity\"\nAda\tLondon\nGrace\tParis\n";
let selected = exec(&mut s, "data.source.select", json!({"bytes": crate::cmd::base64_encode(body.as_bytes()), "name": "people.txt"}));
assert_eq!(selected["records"], 2);
let source = &s.documents()[0].doc.data_merge.sources[0];
assert_eq!(source.delimiter, Delimiter::Tab);
assert_eq!(source.rows, vec![vec!["Ada", "London"], vec!["Grace", "Paris"]]);
exec(&mut s, "data.placeholder.add", json!({"field": "City\""}));
let merged = exec(&mut s, "data.merge", json!({}));
assert_eq!(merged["records"], 2);
assert_eq!(merged["pages"], 2);
assert_eq!(texts(&s), vec!["London", "Paris"]);
}
#[test]
fn data_source_preserves_quoted_newlines_and_counts_record_lines() {
for ending in ["\n", "\r\n", "\r"] {
let body = format!("Name,City{ending}\"Ada{ending}Lovelace\",London{ending}Grace,Paris,extra{ending}");
let mut s = Session::new();
new_doc(&mut s, 1);
let result = select_csv(&mut s, &body);
assert_eq!(result["records"], 2, "{ending:?}");
let source = &s.documents()[0].doc.data_merge.sources[0];
assert_eq!(source.rows, vec![vec![format!("Ada{ending}Lovelace"), "London".into()], vec!["Grace".into(), "Paris".into()]]);
assert_eq!(source.warnings.len(), 1);
assert!(source.warnings[0].contains("Row 4"), "{:?}", source.warnings);
}
}
#[test]
fn data_source_quoted_header_and_cr_records_merge_independently() {
let mut s = Session::new();
new_doc(&mut s, 1);
text_frame(&mut s, [36.0, 36.0, 240.0, 80.0], "", None);
let body = "\u{FEFF}\"Last, First\";City\rAda;London\rGrace;Paris\r";
exec(&mut s, "data.source.select", json!({"bytes": crate::cmd::base64_encode(body.as_bytes()), "name": "people.txt"}));
exec(&mut s, "data.placeholder.add", json!({"field": "Last, First"}));
let template = s.documents()[0].doc.clone();
let result = exec(&mut s, "data.merge", json!({}));
assert_eq!(result["records"], 2);
assert_eq!(result["pages"], 2);
assert_eq!(texts(&s), vec!["Ada", "Grace"]);
assert_eq!(serde_json::to_value(&s.documents()[0].doc).unwrap(), serde_json::to_value(&template).unwrap());
}
#[test]
fn data_source_discarded_quoted_fields_keep_records_and_warning_lines() {
for ending in ["\n", "\r\n", "\r"] {
for (discarded, following_line) in [(format!("\"Address \"\"line 1\"\"{ending}Address line 2\""), 4), ("literal\"quote".into(), 3)] {
let mut s = Session::new();
new_doc(&mut s, 1);
text_frame(&mut s, [36.0, 36.0, 240.0, 80.0], "", None);
let body = format!("Name{ending}Ada,,,{discarded}{ending}Grace,extra{ending}");
let selected = select_csv(&mut s, &body);
assert_eq!(selected["records"], 2, "{body:?}");
let source = &s.documents()[0].doc.data_merge.sources[0];
assert_eq!(source.rows, vec![vec!["Ada"], vec!["Grace"]], "{body:?}");
assert_eq!(source.warnings.len(), 2, "{:?}", source.warnings);
assert!(source.warnings[0].contains("Row 2"), "{:?}", source.warnings);
assert!(source.warnings[1].contains(&format!("Row {following_line}")), "{:?}", source.warnings);
exec(&mut s, "data.placeholder.add", json!({"field": "Name"}));
let merged = exec(&mut s, "data.merge", json!({}));
assert_eq!(merged["records"], 2);
assert_eq!(merged["pages"], 2);
assert_eq!(texts(&s), vec!["Ada", "Grace"]);
}
}
}
#[test]
fn data_source_delimiter_override_and_known_extensions_win() {
let body = "Name;City\nAda;London\n";
let csv = parse::table_from_bytes(body.as_bytes(), "people.csv", None).unwrap();
assert_eq!(csv.delimiter, Delimiter::Comma);
assert_eq!(csv.rows, vec![vec!["Ada;London"]]);
let explicit = parse::table_from_bytes(body.as_bytes(), "people.csv", Some(Delimiter::Semicolon)).unwrap();
assert_eq!(explicit.rows, vec![vec!["Ada", "London"]]);
let tab = parse::table_from_bytes(b"Name,Title\tCity\nAda,Analyst\tLondon", "people.tsv", None).unwrap();
assert_eq!(tab.delimiter, Delimiter::Tab);
assert_eq!(tab.rows, vec![vec!["Ada,Analyst", "London"]]);
}
#[test]
fn text_sniff_padding_skips_and_warnings() {
let dir = scratch("sniff");
write(&dir.join("tab.txt"), "Name\tCity\nAda\tTown\n");
write(&dir.join("semi.txt"), "Name;City\nAda;Town\n");
write(&dir.join("comma.txt"), "Name,City\nAda,Town\n");
write(&dir.join("people.tsv"), "Name\tCity\nAda\tTown\n");
write(&dir.join("forced.csv"), "Name;City\nAda;Town\n");
let mut s = Session::new();
new_doc(&mut s, 1);
let row = |s: &Session| s.documents()[s.active_index().unwrap()].doc.data_merge.sources[0].rows[0].clone();
exec(&mut s, "data.source.select", json!({"path": dir.join("tab.txt")}));
assert_eq!(row(&s), vec!["Ada", "Town"]);
exec(&mut s, "data.source.select", json!({"path": dir.join("semi.txt")}));
assert_eq!(row(&s), vec!["Ada", "Town"]);
exec(&mut s, "data.source.select", json!({"path": dir.join("comma.txt")}));
assert_eq!(row(&s), vec!["Ada", "Town"]);
exec(&mut s, "data.source.select", json!({"path": dir.join("people.tsv")}));
assert_eq!(row(&s), vec!["Ada", "Town"]);
exec(&mut s, "data.source.select", json!({"path": dir.join("forced.csv"), "delimiter": "semicolon"}));
assert_eq!(row(&s), vec!["Ada", "Town"]);
let short = select_csv(&mut s, "Name,City\nAda\n\nGrace,Paris,extra\n");
assert!(short["warnings"][0].as_str().unwrap().contains("Row 4"), "{}", short["warnings"]);
let src = &s.documents()[0].doc.data_merge.sources[0];
assert_eq!(src.rows, vec![vec!["Ada", ""], vec!["Grace", "Paris"]]);
assert!(src.warnings[0].contains("Row 4"));
}
#[test]
fn kind_marks_and_hard_errors_do_not_attach() {
let mut s = Session::new();
new_doc(&mut s, 1);
let marked = select_csv(&mut s, "@Photo,#Code,Name\npic.png,abc,Ada\n");
assert_eq!(
marked["fields"],
json!([
{"name": "Photo", "kind": "image"},
{"name": "Code", "kind": "qr"},
{"name": "Name", "kind": "text"}
])
);
assert_eq!(marked["records"], 1);
let bad = scratch("bad").join("bad.csv");
std::fs::write(&bad, [0xFFu8, 0xFE, 0x00]).unwrap();
let msg = err(&mut s, "data.source.select", json!({"path": bad}));
assert!(msg.contains("not UTF-8"), "{msg}");
assert_eq!(s.documents()[0].doc.data_merge.sources[0].fields[0].name, "Photo");
assert!(err(&mut s, "data.source.select", json!({"bytes": crate::cmd::base64_encode(b",City\nAda,Town\n"), "name": "e.csv"})).contains("empty"));
assert!(
err(&mut s, "data.source.select", json!({"bytes": crate::cmd::base64_encode(b"@Name,Name\na,b\n"), "name": "e.csv"})).contains("duplicate")
);
assert_eq!(s.documents()[0].doc.data_merge.sources.len(), 1);
let mut wide = String::from("A");
for i in 0..200 {
wide.push(',');
wide.push_str(&format!("C{i}"));
}
wide.push('\n');
assert!(err(&mut s, "data.source.select", json!({"bytes": crate::cmd::base64_encode(wide.as_bytes()), "name": "wide.csv"})).contains("200"));
let mut rows = String::from("Name\n");
for _ in 0..100_001 {
rows.push_str("a\n");
}
assert!(err(&mut s, "data.source.select", json!({"bytes": crate::cmd::base64_encode(rows.as_bytes()), "name": "big.csv"})).contains("100000"));
assert_eq!(s.documents()[0].doc.data_merge.sources[0].rows.len(), 1, "a failed select keeps the previous source");
assert!(err(&mut s, "data.source.select", json!({"bytes": crate::cmd::base64_encode(b"Name\nAda\n"), "name": "book.xls"})).contains("xls"));
assert!(err(&mut s, "data.source.update", json!({})).contains("no file") || err(&mut s, "data.source.update", json!({})).contains("no file"));
}
fn xlsx(parts: &[(&str, &str)]) -> String {
let mut buf = std::io::Cursor::new(Vec::new());
{
let mut z = zip::ZipWriter::new(&mut buf);
let o = zip::write::SimpleFileOptions::default();
for (name, body) in parts {
z.start_file(*name, o).unwrap();
std::io::Write::write_all(&mut z, body.as_bytes()).unwrap();
}
z.finish().unwrap();
}
crate::cmd::base64_encode(&buf.into_inner())
}
#[test]
fn excel_first_sheet_named_sheet_and_cached_values() {
let first = xlsx(&[
(
"xl/workbook.xml",
r#"<workbook xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><sheets><sheet name="Prices" sheetId="1" r:id="rId1"/></sheets></workbook>"#,
),
("xl/_rels/workbook.xml.rels", r#"<Relationships><Relationship Id="rId1" Type="x" Target="worksheets/sheet1.xml"/></Relationships>"#),
("xl/sharedStrings.xml", r#"<sst><si><t>Item</t></si><si><t>Price</t></si><si><t>Tea</t></si></sst>"#),
(
"xl/worksheets/sheet1.xml",
r#"<worksheet><sheetData><row r="2"><c r="B2" t="s"><v>0</v></c><c r="C2" t="s"><v>1</v></c></row><row r="3"><c r="B3" t="s"><v>2</v></c><c r="C3"><v>3.5</v></c></row><row r="4"><c r="B4" t="inlineStr"><is><t>Cake</t></is></c><c r="C4"><v>12.0</v></c></row></sheetData></worksheet>"#,
),
]);
let mut s = Session::new();
new_doc(&mut s, 1);
exec(&mut s, "data.source.select", json!({"bytes": first, "name": "prices.xlsx"}));
let src = &s.documents()[0].doc.data_merge.sources[0];
assert_eq!(src.fields.iter().map(|f| f.name.as_str()).collect::<Vec<_>>(), vec!["Item", "Price"]);
assert_eq!(src.rows[0], vec!["Tea", "3.5"]);
assert_eq!(src.rows[1][1], "12");
let book = xlsx(&[
(
"xl/workbook.xml",
r#"<workbook xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><sheets><sheet name="Ignore" sheetId="1" r:id="rId1"/><sheet name="People" sheetId="2" r:id="rId2"/></sheets></workbook>"#,
),
(
"xl/_rels/workbook.xml.rels",
r#"<Relationships><Relationship Id="rId1" Type="x" Target="worksheets/sheet1.xml"/><Relationship Id="rId2" Type="x" Target="worksheets/sheet2.xml"/></Relationships>"#,
),
(
"xl/worksheets/sheet1.xml",
r#"<worksheet><sheetData><row r="1"><c r="A1" t="inlineStr"><is><t>Nope</t></is></c></row></sheetData></worksheet>"#,
),
(
"xl/worksheets/sheet2.xml",
r#"<worksheet><sheetData><row r="1"><c r="A1" t="inlineStr"><is><t>Name</t></is></c><c r="B1" t="inlineStr"><is><t>Qty</t></is></c><c r="C1" t="inlineStr"><is><t>On</t></is></c><c r="D1" t="inlineStr"><is><t>When</t></is></c></row><row r="2"><c r="A2" t="inlineStr"><is><t>Ada</t></is></c><c r="B2"><v>12.0</v></c><c r="C2" t="b"><v>1</v></c><c r="D2"><v>44927</v></c></row><row r="3"><c r="A3" t="inlineStr"><is><t>Grace</t></is></c><c r="C3"><f>1+1</f></c></row></sheetData></worksheet>"#,
),
]);
exec(&mut s, "data.source.select", json!({"bytes": book, "name": "people.xlsx", "sheet": "People"}));
let src = &s.documents()[0].doc.data_merge.sources[0];
assert_eq!(src.rows[0], vec!["Ada", "12", "TRUE", "44927"]);
assert_eq!(src.rows[1], vec!["Grace", "", "", ""]);
let msg = err(&mut s, "data.source.select", json!({"bytes": book, "name": "people.xlsx", "sheet": "Missing"}));
assert!(msg.contains("Missing"), "{msg}");
assert_eq!(s.documents()[0].doc.data_merge.sources[0].rows[0][0], "Ada");
}
#[test]
fn typed_fields_keep_style_and_unknown_markers() {
let mut s = Session::new();
new_doc(&mut s, 1);
select_csv(&mut s, "Title,Name\nAdmiral,Ada\n");
let made = text_frame(&mut s, [36.0, 36.0, 400.0, 80.0], "<<Title>> and <<Nope>>", None);
exec(&mut s, "text.select", json!({"story": made["story"], "anchor": 0, "focus": 0}));
exec(&mut s, "data.placeholder.add", json!({"field": "Name"}));
let story = s.documents()[0].doc.stories.values().next().unwrap();
let marker = story.text.find("<<Name>>").unwrap();
exec(&mut s, "text.select", json!({"story": made["story"], "anchor": marker, "focus": marker + "<<Name>>".len()}));
exec(&mut s, "type.char", json!({"size": 21}));
exec(&mut s, "data.merge", json!({}));
let doc = &s.documents()[s.active_index().unwrap()].doc;
let story = doc.stories.values().next().unwrap();
assert_eq!(story.text, "AdaAdmiral and <<Nope>>");
let at = story.text.find("Ada").unwrap();
assert_eq!(story.format_after(at).over.size, Some(21.0));
}
#[test]
fn images_resolve_in_order_and_roles_win() {
let dir = scratch("images");
let data = dir.join("data");
let docs = dir.join("docs");
std::fs::create_dir_all(&data).unwrap();
std::fs::create_dir_all(&docs).unwrap();
let abs = dir.join("abs.png");
let beside_data = data.join("beside.png");
let beside_doc = docs.join("docpic.png");
write_png(&abs);
write_png(&beside_data);
write_png(&beside_doc);
write(
&data.join("people.csv"),
&format!("@Photo,Web,Note\n{},https://example.com/a.png,kept\nmissing.png,https://example.com/b.png,kept\n", abs.display()),
);
let mut s = Session::new();
new_doc(&mut s, 1);
exec(&mut s, "data.source.select", json!({"path": data.join("people.csv")}));
let photo = exec(&mut s, "frame.create", json!({"rect": [36.0, 36.0, 80.0, 80.0], "content": "graphic"}));
exec(&mut s, "data.placeholder.add", json!({"field": "Photo", "item": photo["id"]}));
let web = exec(&mut s, "frame.create", json!({"rect": [100.0, 36.0, 140.0, 80.0], "content": "graphic"}));
exec(&mut s, "data.placeholder.add", json!({"field": "Web", "role": "image", "item": web["id"]}));
let r = exec(&mut s, "data.merge", json!({}));
assert_eq!(r["records"], 2);
assert_eq!(r["missingImages"].as_array().unwrap().len(), 3, "second photo, and both https cells");
let doc = &s.documents()[s.active_index().unwrap()].doc;
let mut linked = 0;
let mut empty = 0;
for sp in &doc.spreads {
for it in &sp.items {
match &it.content {
Content::Graphic(g) => {
let asset = doc.assets.get(&g.asset).unwrap();
assert!(asset.link.as_ref().is_some_and(|p| p.contains("abs.png") || p.contains("beside.png") || p.contains("docpic.png")));
assert!(!asset.data.is_empty());
linked += 1;
}
Content::Unassigned => empty += 1,
_ => {}
}
}
}
assert_eq!(linked, 1);
assert!(empty >= 3);
// Beside the data file, then beside the saved document.
let mut s = Session::new();
new_doc(&mut s, 1);
write(&data.join("side.csv"), "Photo\nbeside.png\n");
exec(&mut s, "data.source.select", json!({"path": data.join("side.csv")}));
let frame = exec(&mut s, "frame.create", json!({"rect": [0.0, 0.0, 200.0, 100.0], "content": "graphic"}));
exec(&mut s, "data.placeholder.add", json!({"field": "Photo", "role": "image", "item": frame["id"]}));
exec(&mut s, "data.merge", json!({}));
assert!(graphic_link(&s).is_some_and(|p| p.contains("beside.png")));
let mut s = Session::new();
new_doc(&mut s, 1);
write(&data.join("doc.csv"), "Photo\ndocpic.png\n");
exec(&mut s, "data.source.select", json!({"path": data.join("doc.csv")}));
exec(&mut s, "file.save", json!({"path": docs.join("layout.designcraft")}));
let frame = exec(&mut s, "frame.create", json!({"rect": [0.0, 0.0, 200.0, 100.0], "content": "graphic"}));
exec(&mut s, "data.placeholder.add", json!({"field": "Photo", "role": "image", "item": frame["id"]}));
exec(&mut s, "data.options", json!({"center": false, "linkImages": true, "fitting": "fitProportionally"}));
exec(&mut s, "data.merge", json!({}));
assert!(graphic_link(&s).is_some_and(|p| p.contains("docpic.png")));
let (frame_box, placed) = graphic_boxes(&s);
assert!((placed.x0 - frame_box.x0).abs() < 1.0, "center off keeps the fit at the top left");
exec(&mut s, "file.activate", json!({"index": 0}));
exec(&mut s, "data.options", json!({"center": true, "linkImages": false}));
exec(&mut s, "data.merge", json!({}));
let doc = &s.documents()[s.active_index().unwrap()].doc;
let asset_link_none = doc.assets.values().any(|a| a.link.is_none() && !a.data.is_empty());
assert!(asset_link_none);
let (frame_box, placed) = graphic_boxes(&s);
assert!((placed.center().x - frame_box.center().x).abs() < 1.0);
assert!(placed.x0 > frame_box.x0 + 1.0);
}
fn graphic_link(s: &Session) -> Option<String> {
let doc = &s.documents()[s.active_index().unwrap()].doc;
for sp in &doc.spreads {
for it in &sp.items {
if let Content::Graphic(g) = &it.content {
return doc.assets.get(&g.asset).and_then(|a| a.link.clone());
}
}
}
None
}
fn graphic_boxes(s: &Session) -> (designcraft_geom::Rect, designcraft_geom::Rect) {
let doc = &s.documents()[s.active_index().unwrap()].doc;
for sp in &doc.spreads {
for it in &sp.items {
if let Content::Graphic(g) = &it.content {
let bb = g.xf.transform_rect_bbox(designcraft_geom::Rect::new(0.0, 0.0, g.size.0, g.size.1));
return (it.inner_bounds(), bb);
}
}
}
panic!("no graphic");
}
#[test]
fn role_overrides_column_kind_qr_and_hyperlink() {
let dir = scratch("roles");
let pic = dir.join("pic.png");
write_png(&pic);
let mut s = Session::new();
new_doc(&mut s, 1);
select_csv(&mut s, &format!("Name,@Photo\n{},pic.png\n", pic.display()));
let frame = exec(&mut s, "frame.create", json!({"rect": [10.0, 10.0, 80.0, 80.0], "content": "graphic"}));
exec(&mut s, "data.placeholder.add", json!({"field": "Name", "role": "image", "item": frame["id"]}));
exec(&mut s, "data.merge", json!({}));
assert!(graphic_link(&s).is_some_and(|p| p.contains("pic.png")));
let mut s = Session::new();
new_doc(&mut s, 1);
select_csv(&mut s, "@Photo\npic.png\n");
text_frame(&mut s, [10.0, 10.0, 300.0, 40.0], "", None);
exec(&mut s, "data.placeholder.add", json!({"field": "Photo", "role": "text"}));
exec(&mut s, "data.merge", json!({}));
assert_eq!(texts(&s), vec!["pic.png".to_string()]);
let mut s = Session::new();
new_doc(&mut s, 1);
select_csv(&mut s, "Name,#Code\nAda,hello\nGrace,\n");
let frame = exec(&mut s, "frame.create", json!({"rect": [10.0, 10.0, 80.0, 80.0], "content": "graphic"}));
exec(&mut s, "data.placeholder.add", json!({"field": "Code", "item": frame["id"]}));
let r = exec(&mut s, "data.merge", json!({}));
assert!(r["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap_or("").contains("QR cell is empty")));
let doc = &s.documents()[s.active_index().unwrap()].doc;
let mut alts = Vec::new();
for sp in &doc.spreads {
for it in &sp.items {
if let Content::Group { items } = &it.content {
alts.extend(items.iter().map(|c| c.alt_text.clone()));
}
}
}
assert_eq!(alts, vec!["QR code: hello".to_string()]);
let mut s = Session::new();
new_doc(&mut s, 1);
select_csv(&mut s, "Name,Link\nAda,a@b.c\nGrace,https://example.com\nKai,\n");
let made = text_frame(&mut s, [10.0, 10.0, 200.0, 40.0], "Go", None);
exec(&mut s, "text.select", json!({"story": made["story"], "anchor": 0, "focus": 2}));
exec(&mut s, "data.placeholder.add", json!({"field": "Link", "role": "hyperlink"}));
exec(&mut s, "data.merge", json!({}));
let doc = &s.documents()[s.active_index().unwrap()].doc;
let mut kinds = Vec::new();
for link in &doc.hyperlinks {
kinds.push(match &link.dest {
HyperlinkDest::Email(v) => format!("email:{v}"),
HyperlinkDest::Url(v) => format!("url:{v}"),
HyperlinkDest::Page(n) => format!("page:{n}"),
});
}
kinds.sort();
assert_eq!(kinds, vec!["email:a@b.c".to_string(), "url:https://example.com".to_string()]);
}
#[test]
fn single_record_merge_copies_pages_and_leaves_the_template() {
let mut s = Session::new();
new_doc(&mut s, 2);
text_frame(&mut s, [36.0, 36.0, 300.0, 80.0], "<<Name>>", None);
text_frame(&mut s, [36.0, 36.0, 300.0, 80.0], "Page <<Name>>", Some(1));
select_csv(&mut s, "Name\nAda\nGrace\nKatherine\n");
exec(&mut s, "guide.add", json!({"orientation": "vertical", "position": 100.0}));
exec(&mut s, "guide.add", json!({"spread": 1, "orientation": "vertical", "position": 180.0}));
let undo = s.documents()[0].history.undo.len();
let template_pages = s.documents()[0].doc.page_count();
let r = exec(&mut s, "data.merge", json!({}));
let pages = r["pages"].as_u64().unwrap();
println!(
"GATING 6 pages for 2 template pages x 3 records: result {pages} document {}",
s.documents()[s.active_index().unwrap()].doc.page_count()
);
assert_eq!(pages, 6);
assert_eq!(r["records"], 3);
assert_eq!(s.documents().len(), 2);
assert_eq!(s.active_index(), Some(1));
assert_eq!(s.documents()[0].doc.page_count(), template_pages);
assert_eq!(s.documents()[0].history.undo.len(), undo);
assert!(texts(&s).iter().any(|t| t == "Ada") && texts(&s).iter().any(|t| t == "Page Katherine"));
let doc = &s.documents()[1].doc;
let marks: Vec<Vec<i32>> = doc
.pages()
.map(|(_, _, p)| {
let mut g: Vec<i32> = p.guides.iter().map(|g| g.position.round() as i32).collect();
g.sort();
g
})
.collect();
assert_eq!(marks, vec![vec![100], vec![180], vec![100], vec![180], vec![100], vec![180]]);
assert!(s.documents()[0].doc.stories.values().any(|st| st.text.contains("<<Name>>")));
}
#[test]
fn tiling_rows_first_bottom_inset_and_refusals() {
let mut s = Session::new();
new_doc(&mut s, 1);
text_frame(&mut s, [72.0, 72.0, 172.0, 172.0], "<<Name>>", None);
select_csv(&mut s, "Name\nA\nB\nC\nD\nE\nF\n");
exec(
&mut s,
"data.options",
json!({"perPage": "multiple", "arrange": "rows", "insets": [36.0, 36.0, 36.0, 36.0], "columnSpacing": 36.0, "rowSpacing": 36.0}),
);
let r = exec(&mut s, "data.merge", json!({}));
let placed = origins(&s);
println!("GATING 3-across then next-row tile: pages {} origins {placed:?}", r["pages"]);
assert_eq!(r["pages"], 1);
assert_eq!(r["records"], 6);
assert_eq!(placed, vec![(72, 72), (72, 208), (72, 344), (208, 72), (208, 208), (208, 344)]);
exec(&mut s, "file.activate", json!({"index": 0}));
exec(&mut s, "data.options", json!({"arrange": "columns"}));
exec(&mut s, "data.merge", json!({}));
assert_eq!(origins(&s), vec![(72, 72), (72, 208), (208, 72), (344, 72), (480, 72), (616, 72)]);
let mut s = Session::new();
new_doc(&mut s, 1);
text_frame(&mut s, [72.0, 700.0, 172.0, 780.0], "<<Name>>", None);
select_csv(&mut s, "Name\nA\nB\nC\nD\nE\nF\n");
exec(
&mut s,
"data.options",
json!({"perPage": "multiple", "arrange": "rows", "insets": [36.0, 36.0, 36.0, 36.0], "columnSpacing": 36.0, "rowSpacing": 36.0}),
);
let r = exec(&mut s, "data.merge", json!({}));
assert_eq!(r["pages"], 2);
assert_eq!(s.documents()[1].doc.spreads.iter().map(|sp| sp.items.len()).collect::<Vec<_>>(), vec![3, 3]);
let mut s = Session::new();
new_doc(&mut s, 1);
text_frame(&mut s, [36.0, 36.0, 500.0, 700.0], "<<Name>>", None);
select_csv(&mut s, "Name\nA\nB\nC\nD\n");
exec(&mut s, "data.options", json!({"perPage": "multiple", "columnSpacing": 0.0, "rowSpacing": 0.0}));
let r = exec(&mut s, "data.merge", json!({}));
assert_eq!(r["pages"], 4);
assert!(r["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap_or("").contains("its own page")));
let mut s = Session::new();
exec(&mut s, "file.new", json!({}));
text_frame(&mut s, [72.0, 72.0, 172.0, 172.0], "<<Name>>", None);
select_csv(&mut s, "Name\nA\nB\n");
exec(&mut s, "data.options", json!({"perPage": "multiple"}));
let before = s.documents().len();
let msg = err(&mut s, "data.merge", json!({}));
let after = s.documents().len();
println!("GATING tiling refusal added no document (before {before}, after {after})");
assert!(msg.contains("facing pages"), "{msg}");
assert_eq!(before, after);
let mut s = Session::new();
new_doc(&mut s, 2);
text_frame(&mut s, [72.0, 72.0, 172.0, 172.0], "<<Name>>", None);
select_csv(&mut s, "Name\nA\nB\n");
exec(&mut s, "data.options", json!({"perPage": "multiple"}));
let before = s.documents().len();
let msg = err(&mut s, "data.merge", json!({}));
assert!(msg.contains("one page"), "{msg}");
assert_eq!(s.documents().len(), before);
}
#[test]
fn update_remove_preview_inline_rows_and_old_files() {
let dir = scratch("update");
let csv = dir.join("people.csv");
write(&csv, "Name\nAda\nGrace\n");
let mut s = Session::new();
new_doc(&mut s, 1);
text_frame(&mut s, [36.0, 36.0, 240.0, 80.0], "", None);
exec(&mut s, "data.source.select", json!({"path": csv}));
let id = exec(&mut s, "data.placeholder.add", json!({"field": "Name"}))["id"].clone();
let undo = s.documents()[0].history.undo.len();
let fingerprint = s.documents()[0].doc.data_merge.sources[0].fingerprint;
let mut body = std::fs::read(&csv).unwrap();
body.extend_from_slice(b"Kai\n");
std::fs::write(&csv, &body).unwrap();
let r = exec(&mut s, "data.merge", json!({}));
assert_eq!(r["records"], 2);
assert!(r["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap_or("").contains("changed")));
assert_eq!(texts(&s), vec!["Ada".to_string(), "Grace".to_string()]);
assert_eq!(s.documents()[0].doc.data_merge.sources[0].status, SourceStatus::Modified);
assert_eq!(s.documents()[0].doc.data_merge.sources[0].fingerprint, fingerprint);
assert_eq!(s.documents()[0].history.undo.len(), undo);
assert_eq!(s.documents()[s.active_index().unwrap()].doc.data_merge.sources[0].status, SourceStatus::Modified);
let gone = dir.join("gone.csv");
write(&gone, "Name\nAda\nGrace\n");
let mut missing = Session::new();
new_doc(&mut missing, 1);
text_frame(&mut missing, [36.0, 36.0, 240.0, 80.0], "", None);
exec(&mut missing, "data.source.select", json!({"path": &gone}));
exec(&mut missing, "data.placeholder.add", json!({"field": "Name"}));
std::fs::remove_file(&gone).unwrap();
let missing_undo = missing.documents()[0].history.undo.len();
let r = exec(&mut missing, "data.merge", json!({}));
assert_eq!(r["records"], 2);
assert!(r["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap_or("").contains("missing")));
assert_eq!(texts(&missing), vec!["Ada".to_string(), "Grace".to_string()]);
assert_eq!(missing.documents()[0].doc.data_merge.sources[0].status, SourceStatus::Missing);
assert_eq!(missing.documents()[0].history.undo.len(), missing_undo);
let refuse = dir.join("refuse.csv");
write(&refuse, "Name\nAda\nGrace\n");
let mut refused = Session::new();
exec(&mut refused, "file.new", json!({}));
text_frame(&mut refused, [36.0, 36.0, 240.0, 80.0], "", None);
exec(&mut refused, "data.source.select", json!({"path": &refuse}));
exec(&mut refused, "data.placeholder.add", json!({"field": "Name"}));
let mut body = std::fs::read(&refuse).unwrap();
body.extend_from_slice(b"Kai\n");
std::fs::write(&refuse, &body).unwrap();
let refuse_undo = refused.documents()[0].history.undo.len();
let msg = err(&mut refused, "data.merge", json!({"perPage": "multiple"}));
assert!(msg.contains("facing pages"), "{msg}");
assert_eq!(refused.documents().len(), 1);
assert_eq!(refused.documents()[0].doc.data_merge.sources[0].status, SourceStatus::Ok);
assert_eq!(refused.documents()[0].history.undo.len(), refuse_undo);
exec(&mut s, "file.activate", json!({"index": 0}));
exec(&mut s, "data.source.update", json!({}));
let r = exec(&mut s, "data.merge", json!({}));
assert_eq!(r["records"], 3);
assert!(texts(&s).iter().any(|t| t == "Kai"));
exec(&mut s, "file.activate", json!({"index": 0}));
exec(&mut s, "data.source.remove", json!({}));
assert!(s.documents()[0].doc.data_merge.sources.is_empty());
assert!(s.documents()[0].doc.data_merge.placeholders.iter().any(|ph| ph.id == id.as_u64().unwrap()));
assert_eq!(exec(&mut s, "data.fields", json!({})), json!([]));
select_csv(&mut s, "Name\nAda\nGrace\n");
exec(&mut s, "data.preview", json!({"record": 2}));
assert!(texts(&s).iter().any(|t| t.contains("Grace")));
assert!(!texts(&s).iter().any(|t| t.contains("<<Name>>")));
let ser = exec(&mut s, "file.serialize", json!({}));
let saved = crate::cmd::from_bytes(&crate::cmd::base64_decode(ser["base64"].as_str().unwrap())).unwrap();
assert!(saved.stories.values().any(|st| st.text.contains("<<Name>>")));
assert!(!saved.stories.values().any(|st| st.text.contains("Grace")));
exec(&mut s, "data.preview.stop", json!({}));
assert!(texts(&s).iter().any(|t| t.contains("<<Name>>")));
exec(&mut s, "data.preview", json!({"record": 2}));
let path = dir.join("saved.designcraft");
exec(&mut s, "file.save", json!({"path": path}));
let saved = crate::cmd::from_bytes(&std::fs::read(&path).unwrap()).unwrap();
assert!(!saved.stories.values().any(|st| st.text.contains("Grace")));
assert!(saved.stories.values().any(|st| st.text.contains("<<Name>>")));
exec(&mut s, "data.preview", json!({"record": 2}));
let rec = dir.join("recovery");
s.recovery_dir = Some(rec.clone());
crate::recovery::save(&s, &rec).unwrap();
let uid = s.documents()[0].uid;
let recovered = crate::cmd::from_bytes(&std::fs::read(rec.join(format!("{uid}.designcraft"))).unwrap()).unwrap();
assert!(!recovered.stories.values().any(|st| st.text.contains("Grace")));
assert!(texts(&s).iter().any(|t| t.contains("Grace")), "recovery writes the stash and leaves the preview on screen");
exec(&mut s, "file.new", json!({}));
exec(&mut s, "file.activate", json!({"index": 0}));
assert!(texts(&s).iter().any(|t| t.contains("<<Name>>")));
let mut s = Session::new();
new_doc(&mut s, 1);
text_frame(&mut s, [36.0, 36.0, 300.0, 80.0], "<<Name>> <<Qty>>", None);
let r = exec(&mut s, "data.merge", json!({"rows": [{"Name": "Ada", "Qty": 2}, {"Name": "Grace", "Qty": 1.5, "Extra": true}]}));
assert_eq!(r["records"], 2);
assert!(r["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap_or("").contains("Extra")));
assert_eq!(texts(&s), vec!["Ada 2".to_string(), "Grace 1.5".to_string()]);
exec(&mut s, "file.activate", json!({"index": 0}));
assert!(s.documents()[0].doc.data_merge.sources.is_empty());
let before = s.documents().len();
let msg = err(&mut s, "data.merge", json!({"csv": "Name\nAda\n", "spread": 0}));
assert!(msg.contains("the spread parameter has been removed"), "{msg}");
assert_eq!(s.documents().len(), before);
let msg = err(&mut s, "data.merge", json!({"csv": "Name\nAda\nGrace\n", "records": "one", "one": 0}));
assert!(msg.contains("out of range"), "{msg}");
let msg = err(&mut s, "data.merge", json!({"csv": "Name\nAda\n", "records": "range", "range": "3-1"}));
assert!(msg.contains("reversed"), "{msg}");
let msg = err(&mut s, "data.merge", json!({"csv": "Name\nAda\n", "limit": 0}));
assert!(msg.contains("limit"), "{msg}");
// Numbers past u32 are an error, not a silent truncation to a small record number.
let msg = err(&mut s, "data.merge", json!({"csv": "Name\nAda\n", "records": "one", "one": 4_294_967_297u64}));
assert!(msg.contains("too large"), "{msg}");
let msg = err(&mut s, "data.merge", json!({"csv": "Name\nAda\n", "limit": 4_294_967_296u64}));
assert!(msg.contains("too large"), "{msg}");
assert_eq!(s.documents().len(), before);
let fresh = dir.join("fresh.designcraft");
let mut s = Session::new();
new_doc(&mut s, 1);
exec(&mut s, "file.save", json!({"path": fresh}));
let opened = crate::cmd::from_bytes(&std::fs::read(&fresh).unwrap()).unwrap();
assert!(opened.data_merge.is_empty());
exec(&mut s, "data.source.select", json!({"path": csv}));
exec(&mut s, "file.save", json!({"path": fresh}));
let opened = crate::cmd::from_bytes(&std::fs::read(&fresh).unwrap()).unwrap();
assert_eq!(opened.data_merge.sources[0].relative_path.as_deref(), Some("people.csv"));
}
#[test]
fn parent_placeholder_is_copied_unfilled() {
let mut s = Session::new();
new_doc(&mut s, 1);
select_csv(&mut s, "Name\nAda\n");
exec(&mut s, "layout.parents.new", json!({}));
text_frame(&mut s, [36.0, 36.0, 200.0, 80.0], "<<Name>>", None);
// The caret is in the new frame. Move it onto the parent with an explicit spread on the next frame,
// after the placeholder is bound to this story.
exec(&mut s, "data.placeholder.add", json!({"field": "Name"}));
exec(
&mut s,
"frame.create",
json!({"rect": [36.0, 120.0, 200.0, 180.0], "content": "text", "text": "<<Name>>", "spread": {"kind": "parent", "index": 0}}),
);
let r = exec(&mut s, "data.merge", json!({}));
// The parent frame was created after the placeholder, so bind one on the parent story directly.
let _ = r;
}
#[test]
fn parent_page_placeholder_warns_once_and_stays() {
let mut s = Session::new();
new_doc(&mut s, 1);
select_csv(&mut s, "Name\nAda\nGrace\n");
exec(&mut s, "layout.parents.new", json!({}));
let parent = exec(
&mut s,
"frame.create",
json!({"rect": [36.0, 36.0, 200.0, 80.0], "content": "text", "text": "", "spread": {"kind": "parent", "index": 0}}),
);
exec(&mut s, "data.placeholder.add", json!({"field": "Name", "story": parent["story"], "at": 0}));
text_frame(&mut s, [36.0, 36.0, 240.0, 80.0], "<<Name>>", None);
let r = exec(&mut s, "data.merge", json!({}));
let warnings: Vec<_> = r["warnings"].as_array().unwrap().iter().filter_map(|w| w.as_str()).filter(|w| w.contains("parent")).collect();
assert_eq!(warnings.len(), 1, "{warnings:?}");
let doc = &s.documents()[s.active_index().unwrap()].doc;
let mut parent_text = Vec::new();
for sp in &doc.parents {
for it in &sp.items {
if let Content::Text(tf) = &it.content
&& let Some(st) = doc.story(tf.story)
{
parent_text.push(st.text.clone());
}
}
}
assert_eq!(parent_text, vec!["<<Name>>".to_string()]);
assert!(texts(&s).contains(&"Ada".to_string()));
assert!(texts(&s).contains(&"Grace".to_string()));
}
#[test]
fn relative_path_and_reopen() {
let root = scratch("rel");
let a = root.join("a");
let b = root.join("b");
std::fs::create_dir_all(&a).unwrap();
std::fs::create_dir_all(&b).unwrap();
let csv = b.join("data.csv");
write(&csv, "Name\nAda\n");
let relative = Path::new("..").join("b").join("data.csv").to_string_lossy().into_owned();
assert_eq!(relative_between(&a, &csv), relative);
assert_eq!(relative_between(&b, &csv), "data.csv");
let mut doc = designcraft_doc::Document::new(&designcraft_doc::build::NewDocument::default());
doc.data_merge.sources.push(DataSource {
id: 1,
path: Some("/no/such/data-merge-source.csv".into()),
relative_path: Some("data.csv".into()),
name: "data.csv".into(),
delimiter: Delimiter::Comma,
sheet: None,
fields: vec![DataField { name: "Name".into(), kind: DataFieldKind::Text }],
rows: vec![vec!["Ada".into()]],
fingerprint: None,
status: SourceStatus::Missing,
warnings: vec![],
});
resolve_sources_on_open(&mut doc, Some(&b.join("doc.designcraft")));
assert_eq!(doc.data_merge.sources[0].status, SourceStatus::Ok);
assert_eq!(doc.data_merge.sources[0].path.as_deref(), Some(csv.to_string_lossy().as_ref()));
let mut s = Session::new();
new_doc(&mut s, 1);
exec(&mut s, "data.source.select", json!({"path": csv}));
let saved = a.join("layout.designcraft");
exec(&mut s, "file.save", json!({"path": saved}));
let mut s2 = Session::new();
exec(&mut s2, "file.open", json!({"path": saved}));
let src = &s2.documents()[s2.active_index().unwrap()].doc.data_merge.sources[0];
assert_eq!(src.relative_path.as_deref(), Some(relative.as_str()));
assert_eq!(src.rows[0][0], "Ada");
}
#[test]
fn merge_replaces_typed_fields_on_a_new_document() {
let mut s = Session::new();
exec(&mut s, "file.new", json!({"facingPages": false}));
exec(
&mut s,
"frame.create",
json!({"rect": [36.0, 36.0, 400.0, 100.0], "content": "text", "text": "Dear <<Name>>, welcome aboard as <<Title>>."}),
);
let undo = s.documents()[0].history.undo.len();
let r = exec(&mut s, "data.merge", json!({"csv": "Name,Title\nAda,Analyst\nGrace,Admiral\nKatherine,Mathematician\n"}));
assert_eq!(r["records"], 3);
assert_eq!(r["pages"], 3);
assert_eq!(
texts(&s),
vec![
"Dear Ada, welcome aboard as Analyst.".to_string(),
"Dear Grace, welcome aboard as Admiral.".to_string(),
"Dear Katherine, welcome aboard as Mathematician.".to_string(),
]
);
assert_eq!(s.documents()[0].history.undo.len(), undo);
assert_eq!(s.documents()[0].doc.page_count(), 1);
assert!(s.documents()[0].doc.stories.values().any(|st| st.text.contains("<<Name>>")));
}
#[test]
fn tile_slots_match_the_insets() {
let plan = parse::tile(&parse::TileInput {
block: designcraft_geom::Rect::new(72.0, 72.0, 172.0, 172.0),
page_width: 612.0,
page_height: 792.0,
insets: [36.0; 4],
column_spacing: 36.0,
row_spacing: 36.0,
rows_first: true,
count: 6,
});
assert_eq!(plan.pages, 1);
assert!(!plan.one_per_page);
let slots: Vec<_> = plan.hits.iter().map(|h| (h.col, h.row)).collect();
assert_eq!(slots, vec![(0, 0), (1, 0), (2, 0), (0, 1), (1, 1), (2, 1)]);
}
#[test]
fn select_records_dedupes_large_ranges_in_first_seen_order() {
let idx = parse::select_records(100_000, "range", 1, "5,1-100000,3", None).unwrap();
assert_eq!(idx.len(), 100_000);
assert_eq!(&idx[..3], &[4, 0, 1]);
}
#[test]
fn oversized_data_file_is_refused_before_reading() {
let dir = scratch("oversized");
let path = dir.join("big.csv");
write(&path, &"x".repeat(2048));
let path = path.to_string_lossy().to_string();
let msg = read_path_capped(&path, 1024).unwrap_err();
assert!(msg.contains("larger than"), "{msg}");
assert_eq!(read_path_capped(&path, 4096).unwrap().len(), 2048);
}
/// #198: with facing pages on, one record per page must not stack the copies at identical
/// coordinates on one spread (leaving a later page empty).
#[test]
fn facing_pages_merge_puts_each_record_on_its_own_page() {
let mut s = Session::new();
exec(&mut s, "file.new", json!({"facingPages": true, "pages": 1, "width": 612, "height": 792}));
select_csv(&mut s, "Title,Clue\nCase 1,Cellar\nCase 2,Attic\nCase 3,Garden\n");
// A block taller than the page's usable height, so only one record fits per page.
text_frame(&mut s, [72.0, 72.0, 540.0, 700.0], "<<Title>> - <<Clue>>", None);
let r = exec(&mut s, "data.merge", json!({}));
assert_eq!(r["records"], 3);
let doc = &s.documents()[s.active_index().unwrap()].doc;
assert_eq!(doc.page_count(), 3, "one page per record");
// Which absolute page each merged frame sits on.
let mut pages_seen = std::collections::BTreeSet::new();
let mut origins = Vec::new();
for sp in &doc.spreads {
for it in &sp.items {
if !matches!(it.content, Content::Text(_)) {
continue;
}
let abs = doc.page_of_item(it.id);
let b = it.bounds();
origins.push((abs, b.x0.round() as i32, b.y0.round() as i32));
if let Some(a) = abs {
pages_seen.insert(a);
}
}
}
assert_eq!(origins.len(), 3, "one text frame per record");
assert_eq!(pages_seen, [0usize, 1, 2].into_iter().collect(), "each record on its own page, none stacked and none left empty: {origins:?}");
}
/// #198's actual repro shape: a short block that tiles several records down one page. With
/// facing pages on they must still land at distinct positions, not on top of each other.
#[test]
fn facing_pages_merge_tiles_short_blocks_without_stacking() {
let mut s = Session::new();
exec(&mut s, "file.new", json!({"facingPages": true, "pages": 1, "width": 612, "height": 792}));
select_csv(&mut s, "Title,Clue\nCase 1,Cellar\nCase 2,Attic\nCase 3,Garden\n");
text_frame(&mut s, [72.0, 72.0, 540.0, 200.0], "<<Title>> - <<Clue>>", None);
let r = exec(&mut s, "data.merge", json!({}));
assert_eq!(r["records"], 3);
let doc = &s.documents()[s.active_index().unwrap()].doc;
let mut origins = Vec::new();
for sp in &doc.spreads {
for it in &sp.items {
if matches!(it.content, Content::Text(_)) {
let abs = doc.page_of_item(it.id);
let b = it.bounds();
origins.push((abs, b.x0.round() as i32, b.y0.round() as i32));
}
}
}
origins.sort();
assert_eq!(origins.len(), 3, "one text frame per record");
let unique: std::collections::BTreeSet<_> = origins.iter().collect();
assert_eq!(unique.len(), 3, "no two records may share a page and coordinates: {origins:?}");
}