use easydoc::EasyDoc;
use easydoc_core::{
DocumentBlock, DocumentContent, DocumentList, DocumentListItem, DocumentMeta, DocumentTable,
DocumentTableCell, DocumentTableRow, DocumentTextRun,
};
use proptest::prelude::*;
fn any_run() -> impl Strategy<Value = DocumentTextRun> {
("[a-z]{0,8}", any::<bool>(), any::<bool>(), any::<bool>()).prop_map(
|(text, bold, italic, strike)| DocumentTextRun {
text,
bold,
italic,
strikethrough: strike,
hyperlink: None,
},
)
}
fn any_paragraph(max_runs: usize) -> impl Strategy<Value = DocumentBlock> {
prop::collection::vec(any_run(), 0..max_runs).prop_map(DocumentBlock::Paragraph)
}
fn any_heading() -> impl Strategy<Value = DocumentBlock> {
(1..=6usize, prop::collection::vec(any_run(), 0..3)).prop_map(|(level, runs)| {
DocumentBlock::Heading {
level: level as u8,
runs,
}
})
}
fn any_cell() -> impl Strategy<Value = DocumentTableCell> {
(prop::collection::vec(any_paragraph(3), 0..2), 1u32..3).prop_map(|(blocks, span)| {
DocumentTableCell {
blocks,
column_span: span,
row_span: 1,
}
})
}
fn any_table(max_rows: usize) -> impl Strategy<Value = DocumentBlock> {
prop::collection::vec(prop::collection::vec(any_cell(), 1..3), 1..max_rows).prop_map(|rows| {
DocumentBlock::Table(DocumentTable {
rows: rows
.into_iter()
.map(|cells| DocumentTableRow {
cells,
is_header: false,
})
.collect(),
})
})
}
fn any_list() -> impl Strategy<Value = DocumentBlock> {
(any::<bool>(), prop::collection::vec(any_paragraph(3), 1..4)).prop_map(|(ordered, items)| {
DocumentBlock::List(DocumentList {
ordered,
start_number: None,
items: items
.into_iter()
.map(|blocks| DocumentListItem {
blocks: vec![blocks],
nested: None,
})
.collect(),
})
})
}
fn any_content() -> impl Strategy<Value = DocumentContent> {
prop::collection::vec(
prop_oneof![any_paragraph(4), any_heading(), any_table(4), any_list(),],
0..8,
)
.prop_map(|blocks| DocumentContent {
metadata: DocumentMeta::default(),
blocks,
})
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(256))]
#[test]
fn roundtrip_preserves_block_count(content in any_content()) {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("rt.docx");
EasyDoc::write_content(&content, &path).unwrap();
let loaded = EasyDoc::load(&path).unwrap();
let has_non_empty_content = content.blocks.iter().any(|b| !matches!(b, DocumentBlock::Paragraph(runs) if runs.is_empty()));
if has_non_empty_content {
prop_assert!(!loaded.blocks.is_empty(),
"non-empty content read back empty; wrote {} blocks", content.blocks.len());
}
prop_assert!(loaded.blocks.len() <= content.blocks.len().saturating_mul(3).max(1),
"block explosion: wrote {} read {}", content.blocks.len(), loaded.blocks.len());
}
#[test]
fn roundtrip_preserves_text(content in any_content()) {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("rt_text.docx");
EasyDoc::write_content(&content, &path).unwrap();
let loaded = EasyDoc::load(&path).unwrap();
let mut expected: Vec<String> = Vec::new();
collect_run_texts(&content.blocks, &mut expected);
let all_loaded = format!("{loaded:?}");
for text in expected {
if !text.is_empty() {
prop_assert!(all_loaded.contains(&text),
"text {text:?} lost after roundtrip; loaded: {all_loaded}");
}
}
}
#[test]
fn roundtrip_preserves_headings(content in any_content()) {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("rt_h.docx");
EasyDoc::write_content(&content, &path).unwrap();
let loaded = EasyDoc::load(&path).unwrap();
let levels: Vec<u8> = content.blocks.iter().filter_map(|b| match b {
DocumentBlock::Heading { level, .. } => Some(*level),
_ => None,
}).collect();
for level in levels {
prop_assert!(
format!("{loaded:?}").contains(&format!("level: {level}")),
"heading level {level} lost"
);
}
}
}
fn collect_run_texts(blocks: &[DocumentBlock], out: &mut Vec<String>) {
for block in blocks {
match block {
DocumentBlock::Paragraph(runs) | DocumentBlock::Heading { runs, .. } => {
out.extend(runs.iter().map(|r| r.text.clone()));
}
DocumentBlock::List(list) => {
for item in &list.items {
collect_run_texts(&item.blocks, out);
if let Some(nested) = &item.nested {
for nested_item in &nested.items {
collect_run_texts(&nested_item.blocks, out);
}
}
}
}
DocumentBlock::Table(table) => {
for row in &table.rows {
for cell in &row.cells {
collect_run_texts(&cell.blocks, out);
}
}
}
DocumentBlock::TextBox(blocks) | DocumentBlock::Section { blocks, .. } => {
collect_run_texts(blocks, out);
}
_ => {}
}
}
}