use super::super::commands::{Command, Dir, MoveBy};
use super::super::position::{Place, StoryId};
use super::super::selection::Selection;
use super::{blank_with, fonts, open, texts};
use crate::layout::Unit;
fn type_text(e: &mut super::super::Editor, s: &str) {
for c in s.chars() {
e.execute(Command::InsertText(c.to_string()), fonts());
}
}
#[test]
fn typing_inserts_at_the_caret_and_one_undo_removes_the_run() {
let mut e = open("FX-TEXT.docx");
let before = texts(&e);
type_text(&mut e, "Hello");
let after = texts(&e);
assert!(after[0].starts_with("Hello"), "{:?}", after[0]);
assert_eq!(after[0], format!("Hello{}", before[0]));
e.execute(Command::Undo, fonts());
assert_eq!(texts(&e), before);
e.execute(Command::Redo, fonts());
assert_eq!(texts(&e), after);
}
#[test]
fn enter_splits_and_backspace_joins_back() {
let mut e = open("FX-TEXT.docx");
let before = texts(&e);
for _ in 0..3 {
e.execute(Command::Move { by: MoveBy::Char, dir: Dir::Forward, extend: false }, fonts());
}
e.execute(Command::Enter, fonts());
let split = texts(&e);
assert_eq!(split.len(), before.len() + 1);
assert_eq!(format!("{}{}", split[0], split[1]), before[0]);
e.execute(Command::Delete { dir: Dir::Back, word: false }, fonts());
assert_eq!(texts(&e), before);
e.execute(Command::Undo, fonts());
assert_eq!(texts(&e), split);
e.execute(Command::Undo, fonts());
assert_eq!(texts(&e), before);
}
#[test]
fn a_selection_is_replaced_by_typing() {
let mut e = open("FX-TEXT.docx");
let before = texts(&e);
for _ in 0..5 {
e.execute(Command::Move { by: MoveBy::Char, dir: Dir::Forward, extend: true }, fonts());
}
type_text(&mut e, "X");
let after = texts(&e);
let rest: String = before[0].chars().skip(5).collect();
assert_eq!(after[0], format!("X{rest}"));
}
#[test]
fn end_on_a_wrapped_line_shows_at_its_end_and_typing_goes_to_the_next_line_start() {
let mut e = open("FX-TEXT.docx");
for _ in 0..2 {
e.execute(Command::Move { by: MoveBy::Para, dir: Dir::Forward, extend: false }, fonts());
}
e.execute(Command::Move { by: MoveBy::LineEdge, dir: Dir::Forward, extend: false }, fonts());
let Selection::Caret(p) = e.selection() else { panic!("a caret") };
let Place::Text { offset, affinity, .. } = p.place else { panic!("text") };
let first_len = texts(&e)[2].chars().count() as u32;
assert!(offset.0 < first_len, "the first paragraph wraps");
assert_eq!(affinity, super::super::position::Affinity::Upstream);
let c = e.caret_view().expect("a caret rectangle");
e.execute(Command::Move { by: MoveBy::Char, dir: Dir::Forward, extend: false }, fonts());
let Selection::Caret(q) = e.selection() else { panic!("a caret") };
let Place::Text { offset: o2, .. } = q.place else { panic!("text") };
assert_eq!(o2, Unit(offset.0 + 1));
let c2 = e.caret_view().expect("a caret rectangle");
assert!(c2.top > c.top, "after Right the caret is on the next line");
}
#[test]
fn down_then_up_returns_to_the_goal_x() {
let mut e = open("FX-TEXT.docx");
for _ in 0..12 {
e.execute(Command::Move { by: MoveBy::Char, dir: Dir::Forward, extend: false }, fonts());
}
let x0 = e.caret_view().expect("caret").x;
e.execute(Command::Move { by: MoveBy::Line, dir: Dir::Forward, extend: false }, fonts());
e.execute(Command::Move { by: MoveBy::Line, dir: Dir::Forward, extend: false }, fonts());
e.execute(Command::Move { by: MoveBy::Line, dir: Dir::Back, extend: false }, fonts());
e.execute(Command::Move { by: MoveBy::Line, dir: Dir::Back, extend: false }, fonts());
let x1 = e.caret_view().expect("caret").x;
assert!((x1 - x0).abs() < 6.0, "goal x kept: {x0} → {x1}");
}
#[test]
fn ctrl_end_and_ctrl_home_reach_the_story_ends() {
let mut e = open("FX-TBL-BASIC.docx");
e.execute(Command::Move { by: MoveBy::Story, dir: Dir::Forward, extend: false }, fonts());
let end = e.selection().focus().expect("caret");
assert_eq!(end.story, StoryId::Body);
e.execute(Command::Move { by: MoveBy::Story, dir: Dir::Back, extend: false }, fonts());
let start = e.selection().focus().expect("caret");
assert!(e.index().cmp(&start, &end) == std::cmp::Ordering::Less);
}
#[test]
fn tab_in_the_last_cell_of_a_nested_table_adds_a_row_to_that_table_only() {
let mut e = open("FX-NEST-2.docx");
let fonts = fonts();
let doc = e.document();
let mut target = None;
fn find(blocks: &[crate::docx::Block], depth: usize, out: &mut Option<(u64, u64, usize)>) {
for b in blocks {
if let crate::docx::Block::Table(t) = b {
if depth == 1 {
let last = t.rows.last().and_then(|r| r.cells.last()).and_then(|c| c.blocks.first());
if let Some(crate::docx::Block::Para(p)) = last {
*out = out.or(Some((t.id, p.id, t.rows.len())));
}
}
t.rows.iter().flat_map(|r| r.cells.iter()).for_each(|c| find(&c.blocks, depth + 1, out));
}
}
}
find(&doc.sections[0].blocks, 0, &mut target);
let (inner, para, rows) = target.expect("FX-NEST-2 has a nested table");
let outer_rows: Vec<usize> = doc.sections[0]
.blocks
.iter()
.filter_map(|b| match b {
crate::docx::Block::Table(t) => Some(t.rows.len()),
_ => None,
})
.collect();
e.selection = Selection::Caret(super::super::position::Position::text(StoryId::Body, crate::layout::ParaId(para), Unit(0)));
e.execute(Command::NextCell { back: false }, fonts);
let t = e.index().table(e.document(), crate::layout::TableId(inner)).expect("inner table");
assert_eq!(t.rows.len(), rows + 1, "the inner table gained a row");
let outer_after: Vec<usize> = e.document().sections[0]
.blocks
.iter()
.filter_map(|b| match b {
crate::docx::Block::Table(t) => Some(t.rows.len()),
_ => None,
})
.collect();
assert_eq!(outer_after, outer_rows, "outer tables unchanged");
let chain = e.index().chain_of(&e.selection().focus().expect("caret"));
assert_eq!(chain.len(), 2);
assert_eq!(chain[1].row, rows, "the caret is in the new inner row");
e.execute(Command::Undo, fonts);
let t = e.index().table(e.document(), crate::layout::TableId(inner)).expect("inner table");
assert_eq!(t.rows.len(), rows);
}
#[test]
fn every_click_on_a_line_lands_in_its_paragraph() {
for name in ["FX-NEST-3.docx", "FX-TBL-MERGED.docx", "FX-TEXT.docx"] {
let mut e = open(name);
e.complete(fonts());
let stack = e.stack();
let n = stack.pages.len();
for page in 0..n {
let Some(geo) = e.pages.page(page, &e.index) else { continue };
for l in &geo.lines {
if geo.stories[l.story].story != StoryId::Body || l.stops.len() < 2 {
continue;
}
let x = (l.x0() + l.x1()) / 2.0;
let y = (l.top + l.bottom) / 2.0;
let hit = geo.hit_text(x, y, &|s| s == StoryId::Body).expect("a hit");
assert_eq!(hit.para, l.para, "{name} page {page}: click at ({x:.1}, {y:.1})");
}
}
}
}
#[test]
fn a_thousand_typed_characters_add_under_a_megabyte_of_undo_history() {
let mut e = open("FX-TEXT.docx");
let before = e.history().bytes();
type_text(&mut e, &"abcdefghij".repeat(100));
assert!(e.history().bytes() - before <= 1 << 20, "{} bytes", e.history().bytes() - before);
assert_eq!(e.history().undo_depth(), 1, "one typing run is one undo step");
}
#[test]
fn added_rows_and_typing_in_them_save_and_reopen() {
let mut e = open("FX-TBL-BASIC.docx");
let last = |e: &super::super::Editor| texts(e).iter().position(|t| t == "T2 R3 C3").expect("the second table's last cell");
let p = last(&e);
let para = super::super::tests::para_id(&e, p);
e.set_caret_for_benchmark(Selection::Caret(super::super::position::Position::text(StoryId::Body, para, Unit(8))));
e.execute(Command::NextCell { back: false }, fonts());
type_text(&mut e, "new row");
let edited = texts(&e);
assert!(edited.contains(&"new row".to_string()));
let bytes = crate::docx::write::save(e.document()).expect("saves");
let again = super::super::Editor::open(&bytes, fonts()).expect("reopens");
assert_eq!(texts(&again), edited);
}
#[test]
fn a_click_on_any_drawn_line_lands_in_that_lines_paragraph() {
use super::super::input::{Input, Message, PointerKind};
use super::super::keymap::Mods;
for f in ["FX-NEST-3.docx", "FX-TBL-MERGED.docx", "FX-TBL-BASIC.docx", "FX-NEST-DEEP.docx", "FX-NEST-MIXED.docx", "FX-OBJ-CELL.docx"] {
let mut e = open(f);
e.complete(fonts());
let stack = e.stack();
let mut seq = 0;
let mut checked = 0;
for (page, g) in e.page_geometries() {
for l in g.lines.iter().filter(|l| !l.repeat && !l.chain.is_empty() && g.stories[l.story].story == StoryId::Body) {
let x = match l.stops.get(1) {
Some(s) => (l.stops[0].x + s.x) / 2.0,
None => l.stops[0].x + 1.0,
};
let y = (l.top + l.bottom) / 2.0;
let own = g.cells.iter().find(|c| c.chain == l.chain).expect("the line's cell");
if x < own.rect[0] || x > own.rect[2] {
continue;
}
let (dx, dy) = stack.to_doc(page, x, y).expect("on the stack");
for kind in [PointerKind::Down, PointerKind::Up] {
seq += 1;
e.apply(Message { seq, input: Input::Pointer { kind, x: dx, y: dy, clicks: 1, mods: Mods::default() } }, fonts())
.expect("pointer");
}
let got = match e.selection() {
crate::editing::selection::Selection::Object(crate::editing::selection::ObjectRef::Inline { para, .. }) => Some(para),
s => s.focus().and_then(|p| p.para_offset()).map(|(p, _)| p),
};
let txt = |p: Option<crate::layout::ParaId>| {
p.and_then(|p| e.index().para(e.document(), p))
.map(|q| super::super::text::UnitText::of(q).text.chars().take(40).collect::<String>())
};
assert_eq!(
got,
Some(l.para),
"{f}: a click at ({x:.1}, {:.1}) on a line of {:?} {:?} (page {page}, chain {:?}) landed in {:?}",
(l.top + l.bottom) / 2.0,
txt(Some(l.para)),
l.para,
l.chain,
txt(got)
);
checked += 1;
}
}
assert!(checked > 5, "{f}: only {checked} table lines");
}
}
#[test]
fn a_click_on_any_story_line_lands_in_that_line() {
use super::super::input::{Input, Message, PointerKind};
use super::super::keymap::Mods;
let mut e = open("FX-STORIES.docx");
e.complete(fonts());
let stack = e.stack();
let mut seq = 0;
let mut seen = std::collections::HashSet::new();
for (page, g) in e.page_geometries() {
for l in g.lines.iter().filter(|l| l.stops.len() > 1) {
let story = g.stories[l.story].story;
if !seen.insert((story, l.para)) {
continue;
}
let x = (l.stops[0].x + l.stops[1].x) / 2.0;
let (dx, dy) = stack.to_doc(page, x, (l.top + l.bottom) / 2.0).expect("on the stack");
let clicks = if matches!(story, StoryId::HeaderFooter(_)) { 2 } else { 1 };
for c in 1..=clicks {
for kind in [PointerKind::Down, PointerKind::Up] {
seq += 1;
e.apply(Message { seq, input: Input::Pointer { kind, x: dx, y: dy, clicks: c, mods: Mods::default() } }, fonts())
.expect("pointer");
}
}
let got = e.selection().focus().map(|p| (p.story, p.para_offset().map(|(p, _)| p)));
assert_eq!(
got,
Some((story, Some(l.para))),
"a click on page {page} at ({x:.1}, {:.1}) on {story:?} {:?}; stories {:?}",
(l.top + l.bottom) / 2.0,
l.para,
g.stories.iter().map(|s| (s.story, s.rect.map(|v| v.round()))).collect::<Vec<_>>()
);
seq += 1;
e.apply(Message { seq, input: Input::Key { code: "Escape".into(), mods: Mods::default() } }, fonts()).expect("key");
}
}
assert!(seen.len() > 20, "only {} story lines", seen.len());
}
#[test]
fn a_deleted_float_stays_deleted_after_saving() {
let count = |e: &super::super::Editor| {
fn walk(b: &[crate::docx::Block]) -> usize {
b.iter()
.map(|b| match b {
crate::docx::Block::Para(p) => p.inlines.iter().filter(|i| matches!(i, crate::docx::Inline::Float(..))).count(),
crate::docx::Block::Table(t) => t.rows.iter().flat_map(|r| r.cells.iter()).map(|c| walk(&c.blocks)).sum(),
})
.sum()
}
e.document().sections.iter().map(|s| walk(&s.blocks)).sum::<usize>()
};
let mut e = open("FX-OBJ.docx");
let before = count(&e);
let f = e
.document()
.sections
.iter()
.flat_map(|s| s.blocks.iter())
.find_map(|b| match b {
crate::docx::Block::Para(p) => p.inlines.iter().find_map(|i| match i {
crate::docx::Inline::Float(f, _) => Some(crate::layout::FloatId(f.id)),
_ => None,
}),
_ => None,
})
.expect("a float");
e.set_caret_for_benchmark(Selection::Object(crate::editing::selection::ObjectRef::Floating(f)));
e.execute(Command::Delete { dir: super::super::commands::Dir::Forward, word: false }, fonts());
assert_eq!(count(&e), before - 1);
let edited = texts(&e);
let bytes = crate::docx::write::save(e.document()).expect("saves");
let again = super::super::Editor::open(&bytes, fonts()).expect("reopens");
assert_eq!(count(&again), before - 1);
assert_eq!(texts(&again), edited);
}
#[test]
fn edits_in_every_story_save_and_reopen() {
use crate::docx::{Block, Inline};
fn first_para(b: &[Block]) -> Option<&crate::docx::Paragraph> {
b.iter().find_map(|b| if let Block::Para(p) = b { Some(p) } else { None })
}
fn text_box(doc: &crate::docx::Document) -> Option<(u64, &[Block])> {
doc.sections.iter().flat_map(|s| s.blocks.iter()).find_map(|b| match b {
Block::Para(p) => p.inlines.iter().find_map(|i| match i {
Inline::Float(f, _) => f.text_box.as_ref().map(|t| (f.id, t.blocks.as_slice())),
_ => None,
}),
_ => None,
})
}
let text = |p: &crate::docx::Paragraph| super::super::text::UnitText::of(p).text;
let mut e = open("FX-STORIES.docx");
let header = e.document().stories.keys().min().cloned().expect("a header");
let targets: Vec<(StoryId, crate::layout::ParaId)> = {
let d = e.document();
let fid = *d.footnotes.keys().min().unwrap();
let eid = *d.endnotes.keys().min().unwrap();
let (tb, tbb) = text_box(d).expect("a text box");
vec![
(e.index().header_footer(&header).unwrap(), crate::layout::ParaId(first_para(&d.stories[&header]).unwrap().id)),
(StoryId::Footnote(fid), crate::layout::ParaId(first_para(&d.footnotes[&fid]).unwrap().id)),
(StoryId::Endnote(eid), crate::layout::ParaId(first_para(&d.endnotes[&eid]).unwrap().id)),
(StoryId::TextBox(crate::layout::FloatId(tb)), crate::layout::ParaId(first_para(tbb).unwrap().id)),
]
};
for (story, para) in targets {
e.set_caret_for_benchmark(Selection::Caret(super::super::position::Position::text(story, para, Unit(1))));
type_text(&mut e, "Q");
}
let snap = |d: &crate::docx::Document| {
let mut f: Vec<String> = d.footnotes.values().chain(d.endnotes.values()).filter_map(|b| first_para(b)).map(text).collect();
f.sort();
(text(first_para(&d.stories[&header]).unwrap()), f, text(first_para(text_box(d).unwrap().1).unwrap()))
};
let edited = snap(e.document());
assert!(edited.0.contains('Q') && edited.2.contains('Q') && edited.1.iter().filter(|t| t.contains('Q')).count() == 2, "{edited:?}");
let bytes = crate::docx::write::save(e.document()).expect("saves");
let again = super::super::Editor::open(&bytes, fonts()).expect("reopens");
assert_eq!(snap(again.document()), edited);
assert_eq!(texts(&again), texts(&e));
}
#[test]
fn a_nudged_float_saves_its_new_offsets() {
let floats = |e: &super::super::Editor| -> Vec<(f64, f64)> {
e.document()
.sections
.iter()
.flat_map(|s| s.blocks.iter())
.filter_map(|b| if let crate::docx::Block::Para(p) = b { Some(p) } else { None })
.flat_map(|p| p.inlines.iter())
.filter_map(|i| if let crate::docx::Inline::Float(f, _) = i { Some((f.h_offset, f.v_offset)) } else { None })
.collect()
};
let mut e = open("FX-OBJ.docx");
let id = e
.document()
.sections
.iter()
.flat_map(|s| s.blocks.iter())
.filter_map(|b| if let crate::docx::Block::Para(p) = b { Some(p) } else { None })
.flat_map(|p| p.inlines.iter())
.filter_map(|i| if let crate::docx::Inline::Float(f, _) = i { Some(crate::layout::FloatId(f.id)) } else { None })
.nth(1)
.expect("two floats");
e.set_caret_for_benchmark(Selection::Object(crate::editing::selection::ObjectRef::Floating(id)));
e.execute(Command::Move { by: super::super::commands::MoveBy::Char, dir: super::super::commands::Dir::Forward, extend: false }, fonts());
e.execute(Command::Move { by: super::super::commands::MoveBy::Line, dir: super::super::commands::Dir::Forward, extend: false }, fonts());
let moved = floats(&e);
let bytes = crate::docx::write::save(e.document()).expect("saves");
let again = super::super::Editor::open(&bytes, fonts()).expect("reopens");
let back = floats(&again);
assert_eq!(back.len(), moved.len());
for (a, b) in back.iter().zip(&moved) {
assert!((a.0 - b.0).abs() < 0.01 && (a.1 - b.1).abs() < 0.01, "{back:?} vs {moved:?}");
}
assert_eq!(texts(&again), texts(&e));
}
#[test]
fn a_resized_float_saves_its_new_size() {
use serde_json::json;
let mut r = super::scenarios::Run::open("FX-OBJ-LOCK.docx", "resize-save");
r.step(&json!({ "click": { "float": 1 } }));
r.step(&json!({ "drag": { "from": { "float": 1, "at": "bottom-right" }, "to": { "float": 1, "at": "bottom-right", "dx": 50.4 } } }));
let size = |e: &super::super::Editor| -> Vec<(f64, f64)> {
e.document()
.sections
.iter()
.flat_map(|s| s.blocks.iter())
.filter_map(|b| if let crate::docx::Block::Para(p) = b { Some(p) } else { None })
.flat_map(|p| p.inlines.iter())
.filter_map(|i| if let crate::docx::Inline::Float(f, _) = i { Some((f.w, f.h)) } else { None })
.collect()
};
let edited = size(&r.e);
assert!((edited[1].0 - 151.2).abs() < 0.01 && (edited[1].1 - 108.0).abs() < 0.01, "{edited:?}");
let bytes = crate::docx::write::save(r.e.document()).expect("saves");
if let Some(d) = std::env::var_os("RT_KEEP") {
std::fs::write(std::path::Path::new(&d).join("FX-OBJ.resize.docx"), &bytes).expect("writes");
}
let again = super::super::Editor::open(&bytes, fonts()).expect("reopens");
for (a, b) in size(&again).iter().zip(&edited) {
assert!((a.0 - b.0).abs() < 0.01 && (a.1 - b.1).abs() < 0.01, "{:?} vs {edited:?}", size(&again));
}
}
#[test]
fn resized_columns_and_rows_save_and_reopen() {
use serde_json::json;
let tables = |e: &super::super::Editor| -> Vec<(Vec<f64>, Vec<Option<(f64, String)>>, Vec<Option<crate::docx::props::Width>>)> {
fn walk(b: &[crate::docx::Block], out: &mut Vec<(Vec<f64>, Vec<Option<(f64, String)>>, Vec<Option<crate::docx::props::Width>>)>) {
for b in b {
if let crate::docx::Block::Table(t) = b {
out.push((
t.grid.clone(),
t.rows.iter().map(|r| r.height.clone()).collect(),
t.rows.iter().flat_map(|r| r.cells.iter().map(|c| c.width)).collect(),
));
}
}
}
let mut out = Vec::new();
e.document().sections.iter().for_each(|s| walk(&s.blocks, &mut out));
out
};
let mut r = super::scenarios::Run::open("FX-TBL-BASIC.docx", "resize-table-save");
r.step(&json!({ "drag": { "from": { "table": 1, "row": 0, "border": 1 }, "to": { "table": 1, "row": 0, "border": 1, "dx": 30 } } }));
r.step(
&json!({ "drag": { "from": { "table": 1, "row": 1, "border": "bottom" }, "to": { "table": 1, "row": 1, "border": "bottom", "dy": 20 } } }),
);
let edited = tables(&r.e);
assert_eq!(edited[1].0, vec![186.0, 126.0, 156.0]);
assert!(edited[1].1[1].as_ref().is_some_and(|(_, rule)| rule == "atLeast"), "{:?}", edited[1].1);
let bytes = crate::docx::write::save(r.e.document()).expect("saves");
if let Some(d) = std::env::var_os("RT_KEEP") {
std::fs::write(std::path::Path::new(&d).join("FX-TBL-BASIC.resize.docx"), &bytes).expect("writes");
}
let again = super::super::Editor::open(&bytes, fonts()).expect("reopens");
let back = tables(&again);
assert_eq!(back[1].0, edited[1].0);
let h = |v: &Option<(f64, String)>| v.as_ref().map(|(h, r)| ((h * 20.0).round(), r.clone()));
assert_eq!(back[1].1.iter().map(h).collect::<Vec<_>>(), edited[1].1.iter().map(h).collect::<Vec<_>>());
assert_eq!(back[1].2, edited[1].2);
assert_eq!(texts(&again), texts(&r.e));
}
#[test]
fn a_reanchored_float_saves_in_its_new_paragraph() {
use serde_json::json;
let mut r = super::scenarios::Run::open("FX-OBJ.docx", "reanchor-save");
r.step(&json!({ "drag": { "from": { "float": 1 }, "to": { "float": 1, "dy": -150 } } }));
let floats = |e: &mut super::super::Editor| crate::editing::inspect::inspect(e, "dump", true)["dump"]["floats"].clone();
let edited = floats(&mut r.e);
assert_eq!(edited[0]["anchor"], json!(5), "{edited}");
let bytes = crate::docx::write::save(r.e.document()).expect("saves");
if let Some(d) = std::env::var_os("RT_KEEP") {
std::fs::write(std::path::Path::new(&d).join("FX-OBJ.reanchor.docx"), &bytes).expect("writes");
}
let mut again = super::super::Editor::open(&bytes, fonts()).expect("reopens");
let back = floats(&mut again);
for (a, b) in back.as_array().unwrap().iter().zip(edited.as_array().unwrap()) {
for k in ["anchor", "wrap", "w", "h"] {
assert_eq!(a[k], b[k], "{back}");
}
for k in ["x", "y"] {
assert!((a[k].as_f64().unwrap() - b[k].as_f64().unwrap()).abs() < 0.1, "{back}");
}
}
assert_eq!(texts(&again), texts(&r.e));
}
#[test]
fn a_border_moved_in_selected_rows_saves() {
use serde_json::json;
let mut r = super::scenarios::Run::open("FX-TBL-BASIC.docx", "obs48-save");
r.step(&json!({ "click": { "para": 13, "offset": 2 } }));
r.step(&json!({ "command": "table.select.row" }));
r.step(&json!({ "drag": { "from": { "table": 1, "row": 0, "border": 1 }, "to": { "table": 1, "row": 0, "border": 1, "dx": 30 } } }));
let tables = |e: &mut super::super::Editor| crate::editing::inspect::inspect(e, "dump", true)["dump"]["tables"].clone();
let edited = tables(&mut r.e);
assert_eq!(edited[1]["grid"], json!([156.0, 30.0, 126.0, 156.0]));
let bytes = crate::docx::write::save(r.e.document()).expect("saves");
if let Some(d) = std::env::var_os("RT_KEEP") {
std::fs::write(std::path::Path::new(&d).join("FX-TBL-BASIC.obs48.docx"), &bytes).expect("writes");
}
let mut again = super::super::Editor::open(&bytes, fonts()).expect("reopens");
assert_eq!(tables(&mut again), edited);
assert_eq!(texts(&again), texts(&r.e));
}
#[test]
fn table_formats_save_and_reopen() {
use serde_json::json;
let mut r = super::scenarios::Run::open("FX-TBL-BASIC.docx", "table-format-save");
r.step(&json!({ "drag": { "from": { "para": 13, "offset": 2 }, "to": { "para": 17, "offset": 2 } } }));
r.step(&json!({ "command": "table.cell-align.bottom" }));
r.step(&json!({ "command": "table.shading", "arg": "#DDEBF7" }));
r.step(&json!({ "click": { "para": 13, "offset": 2 } }));
r.step(&json!({ "command": "table.header-row" }));
let tables = |e: &mut super::super::Editor| crate::editing::inspect::inspect(e, "dump", true)["dump"]["tables"].clone();
let edited = tables(&mut r.e);
assert_eq!(edited[1]["header_rows"], json!(1));
let bytes = crate::docx::write::save(r.e.document()).expect("saves");
if let Some(d) = std::env::var_os("RT_KEEP") {
std::fs::write(std::path::Path::new(&d).join("FX-TBL-BASIC.formats.docx"), &bytes).expect("writes");
}
let mut again = super::super::Editor::open(&bytes, fonts()).expect("reopens");
assert_eq!(tables(&mut again), edited);
assert_eq!(texts(&again), texts(&r.e));
}
#[test]
fn an_inserted_footnote_saves_and_deleting_its_reference_deletes_it() {
let note_texts = |e: &super::super::Editor| -> Vec<String> {
let mut v: Vec<(i64, String)> = e
.document()
.footnotes
.iter()
.map(|(id, b)| {
(
*id,
b.iter()
.filter_map(|b| if let crate::docx::Block::Para(p) = b { Some(super::super::text::UnitText::of(p).text) } else { None })
.collect::<String>(),
)
})
.collect();
v.sort();
v.into_iter().map(|(_, t)| t).collect()
};
let mut e = open("FX-STORIES.docx");
let before = note_texts(&e);
let para = super::super::tests::para_id(&e, 1);
e.set_caret_for_benchmark(Selection::Caret(super::super::position::Position::text(StoryId::Body, para, Unit(4))));
e.execute(Command::InsertNote(crate::docx::NoteKind::Foot), fonts());
assert!(matches!(e.selection().focus().map(|p| p.story), Some(StoryId::Footnote(_))), "{:?}", e.selection());
type_text(&mut e, "New note.");
let edited = note_texts(&e);
assert_eq!(edited.len(), before.len() + 1);
assert!(edited.last().unwrap().ends_with(" New note."), "{edited:?}");
let bytes = crate::docx::write::save(e.document()).expect("saves");
if let Some(d) = std::env::var_os("RT_KEEP") {
std::fs::write(std::path::Path::new(&d).join("FX-STORIES.footnote.docx"), &bytes).expect("writes");
}
let again = super::super::Editor::open(&bytes, fonts()).expect("reopens");
assert_eq!(note_texts(&again), edited);
assert_eq!(texts(&again), texts(&e));
e.set_caret_for_benchmark(Selection::Caret(super::super::position::Position::text(StoryId::Body, para, Unit(4))));
e.execute(Command::Delete { dir: super::super::commands::Dir::Forward, word: false }, fonts());
assert_eq!(note_texts(&e), before);
let bytes = crate::docx::write::save(e.document()).expect("saves");
let again = super::super::Editor::open(&bytes, fonts()).expect("reopens");
assert_eq!(note_texts(&again), before);
e.execute(Command::Undo, fonts());
assert_eq!(note_texts(&e), edited);
}
#[test]
fn notes_inserted_in_a_document_without_notes_save() {
let mut e = open("FX-TEXT.docx");
assert!(e.document().footnotes.is_empty() && e.document().endnotes.is_empty());
let para = super::super::tests::para_id(&e, 1);
for kind in [crate::docx::NoteKind::Foot, crate::docx::NoteKind::End] {
e.set_caret_for_benchmark(Selection::Caret(super::super::position::Position::text(StoryId::Body, para, Unit(3))));
e.execute(Command::InsertNote(kind), fonts());
type_text(&mut e, "A note.");
}
let bytes = crate::docx::write::save(e.document()).expect("saves");
if let Some(d) = std::env::var_os("RT_KEEP") {
std::fs::write(std::path::Path::new(&d).join("FX-TEXT.notes.docx"), &bytes).expect("writes");
}
let again = super::super::Editor::open(&bytes, fonts()).expect("reopens");
let text = |m: &std::collections::HashMap<i64, Vec<crate::docx::Block>>| -> Vec<String> {
m.values()
.flat_map(|b| b.iter())
.filter_map(|b| if let crate::docx::Block::Para(p) = b { Some(super::super::text::UnitText::of(p).text) } else { None })
.collect()
};
assert_eq!(text(&again.document().footnotes), vec!["\u{FFFC} A note.".to_string()]);
assert_eq!(text(&again.document().endnotes), vec!["\u{FFFC} A note.".to_string()]);
assert_eq!(texts(&again), texts(&e));
}
#[test]
fn deleted_and_split_cells_save_and_reopen() {
use serde_json::json;
for (cmd, arg) in [("table.delete.cells-left", None), ("table.split-rows", Some("2"))] {
let mut r = super::scenarios::Run::open("FX-TBL-BASIC.docx", "cells-save");
r.step(&json!({ "click": { "para": 14, "offset": 2 } }));
r.step(&json!({ "command": cmd, "arg": arg }));
let tables = |e: &mut super::super::Editor| crate::editing::inspect::inspect(e, "dump", true)["dump"]["tables"].clone();
let merges = |e: &super::super::Editor| -> Vec<Vec<crate::docx::VMerge>> {
e.document().sections[0]
.blocks
.iter()
.filter_map(|b| if let crate::docx::Block::Table(t) = b { Some(t) } else { None })
.nth(1)
.unwrap()
.rows
.iter()
.map(|r| r.cells.iter().map(|c| c.v_merge).collect())
.collect()
};
let edited = tables(&mut r.e);
let bytes = crate::docx::write::save(r.e.document()).expect("saves");
if let Some(d) = std::env::var_os("RT_KEEP") {
std::fs::write(std::path::Path::new(&d).join(format!("FX-TBL-BASIC.{cmd}.docx")), &bytes).expect("writes");
}
let mut again = super::super::Editor::open(&bytes, fonts()).expect("reopens");
assert_eq!(tables(&mut again), edited, "{cmd}");
assert_eq!(merges(&again), merges(&r.e), "{cmd}");
assert_eq!(texts(&again), texts(&r.e), "{cmd}");
}
}
#[test]
fn editing_works_in_every_layout_status() {
use crate::layout::document::{FAIL_VERIFY, HF_ALTERNATE};
let path = std::env::var_os("TWIP_REFERENCE_FIXTURES").map(std::path::PathBuf::from).unwrap_or_default().join("HF9").join("HF9_f_pagenum.docx");
let Ok(bytes) = std::fs::read(path) else { return };
for (fail, want) in [(false, "stabilized"), (true, "inconsistent")] {
HF_ALTERNATE.set(12.0);
FAIL_VERIFY.set(fail);
let mut e = super::super::Editor::open(&bytes, fonts()).expect("opens");
e.complete(fonts());
let status = |e: &mut super::super::Editor| crate::editing::inspect::inspect(e, "sync", true)["sync"]["status"].clone();
assert_eq!(status(&mut e), serde_json::json!(want));
let before = texts(&e);
let para = super::super::tests::para_id(&e, 0);
e.set_caret_for_benchmark(Selection::Caret(super::super::position::Position::text(StoryId::Body, para, Unit(0))));
type_text(&mut e, "Still editable. ");
e.complete(fonts());
assert!(texts(&e)[0].starts_with("Still editable. "));
assert!(crate::docx::write::save(e.document()).is_ok());
e.execute(Command::Undo, fonts());
assert_eq!(texts(&e), before);
HF_ALTERNATE.set(0.0);
FAIL_VERIFY.set(false);
}
}
fn open_large() -> super::super::Editor {
let bytes = std::fs::read(std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/big.docx")).expect("big.docx");
super::super::Editor::open(&bytes, fonts()).expect("opens")
}
#[test]
fn down_right_after_enter_lands_where_the_finished_layout_puts_it() {
let run = |settle: bool| {
let mut e = open_large();
if settle {
e.complete(fonts());
}
let para = super::super::tests::para_id(&e, 2);
e.set_caret_for_benchmark(Selection::Caret(super::super::position::Position::text(StoryId::Body, para, Unit(10))));
e.execute(Command::Enter, fonts());
if settle {
e.complete(fonts());
}
e.execute(Command::Move { by: super::super::commands::MoveBy::Line, dir: super::super::commands::Dir::Forward, extend: false }, fonts());
let (p, u) = e.selection().focus().and_then(|f| f.para_offset()).expect("a caret");
((0..texts(&e).len()).position(|k| super::super::tests::para_id(&e, k) == p), u)
};
assert_eq!(run(false), run(true));
}
#[test]
fn a_late_selection_keeps_its_characters_through_an_early_reflow() {
let mut e = open_large();
e.complete(fonts());
let n = texts(&e).len();
let k = (n * 9 / 10..n).find(|&k| texts(&e)[k].chars().count() > 20).expect("a long late paragraph");
let para = super::super::tests::para_id(&e, k);
let at = |u| super::super::position::Position::text(StoryId::Body, para, Unit(u));
e.set_caret_for_benchmark(Selection::range(at(3), at(15), crate::editing::selection::Granularity::Char));
let before = e.selected_text();
let first = super::super::tests::para_id(&e, 0);
e.set_caret_for_benchmark(Selection::Caret(super::super::position::Position::text(StoryId::Body, first, Unit(0))));
for _ in 0..5 {
e.execute(Command::Enter, fonts());
}
let caret = e.selection();
e.set_caret_for_benchmark(Selection::range(at(3), at(15), crate::editing::selection::Granularity::Char));
assert_eq!(e.selected_text(), before, "the late paragraph's text moved ({caret:?})");
e.complete(fonts());
assert_eq!(e.selected_text(), before);
}
#[test]
fn a_click_on_a_provisional_page_resolves_against_the_finished_layout() {
use super::super::input::{Input, Message, PointerKind};
use super::super::keymap::Mods;
let edited = |settle: bool| {
let mut e = open_large();
e.complete(fonts());
let para = super::super::tests::para_id(&e, 1);
e.set_caret_for_benchmark(Selection::Caret(super::super::position::Position::text(StoryId::Body, para, Unit(0))));
for _ in 0..3 {
e.execute(Command::Enter, fonts());
}
if settle {
e.complete(fonts());
}
e
};
let mut done = edited(true);
let stack = done.stack();
let (page, g) = done.page_geometries().into_iter().find(|(p, _)| *p >= 3).expect("a fourth page");
let l = g.lines.iter().find(|l| l.stops.len() > 3 && g.stories[l.story].story == StoryId::Body).expect("a body line");
let (x, y) = stack.to_doc(page, (l.stops[1].x + l.stops[2].x) / 2.0, (l.top + l.bottom) / 2.0).expect("on the stack");
let click = |e: &mut super::super::Editor| {
for (seq, kind) in [(1, PointerKind::Down), (2, PointerKind::Up)] {
e.apply(Message { seq, input: Input::Pointer { kind, x, y, clicks: 1, mods: Mods::default() } }, fonts()).expect("pointer");
}
let (p, u) = e.selection().focus().and_then(|f| f.para_offset()).expect("a caret");
((0..texts(e).len()).position(|k| super::super::tests::para_id(e, k) == p), u)
};
let want = click(&mut done);
let mut fresh = edited(false);
assert!(fresh.snapshot().info().get(page).is_none_or(|i| i.provisional), "page {page} is provisional after the edit");
assert_eq!(click(&mut fresh), want);
}
#[test]
fn a_row_inserted_in_a_repeating_section_saves_as_its_own_item() {
use crate::docx::ControlKind;
let shape = |e: &super::super::Editor| -> Vec<Vec<String>> {
let d = e.document();
let t = d.sections[0].blocks.iter().find_map(|b| if let crate::docx::Block::Table(t) = b { Some(t) } else { None }).unwrap();
t.rows.iter().map(|r| r.controls.iter().map(|id| format!("{:?}", d.controls.get(id).map(|c| c.props.kind.clone()))).collect()).collect()
};
let distinct_items = |e: &super::super::Editor| -> usize {
let d = e.document();
let t = d.sections[0].blocks.iter().find_map(|b| if let crate::docx::Block::Table(t) = b { Some(t) } else { None }).unwrap();
let mut ids: Vec<crate::docx::ControlId> = t
.rows
.iter()
.flat_map(|r| r.controls.iter().copied())
.filter(|id| d.controls.get(id).is_some_and(|c| c.props.kind == ControlKind::RepeatingItem))
.collect();
ids.sort();
ids.dedup();
ids.len()
};
let mut e = open("FX-REPEAT-REGION.docx");
assert_eq!(distinct_items(&e), 3);
let para = super::super::tests::para_id(&e, 8);
e.set_caret_for_benchmark(Selection::Caret(super::super::position::Position::text(StoryId::Body, para, Unit(2))));
e.execute(Command::InsertRows { below: true, n: 1 }, fonts());
type_text(&mut e, "Item 2b");
let edited = shape(&e);
assert_eq!(edited.len(), 6);
assert_eq!(edited[3], vec!["Some(RepeatingSection)".to_string(), "Some(RepeatingItem)".to_string()]);
assert_eq!(distinct_items(&e), 4);
let bytes = crate::docx::write::save(e.document()).expect("saves");
if let Some(d) = std::env::var_os("RT_KEEP") {
std::fs::write(std::path::Path::new(&d).join("FX-REPEAT-REGION.row.docx"), &bytes).expect("writes");
}
let again = super::super::Editor::open(&bytes, fonts()).expect("reopens");
assert_eq!(shape(&again), edited);
assert_eq!(distinct_items(&again), 4);
assert_eq!(texts(&again), texts(&e));
}
#[test]
fn a_toggled_checkbox_saves_its_state() {
use serde_json::json;
let mut r = super::scenarios::Run::open("FX-REPEAT-REGION.docx", "checkbox-save");
r.step(&json!({ "click": { "para": 7, "offset": 0 } }));
let faces = |e: &super::super::Editor| texts(e).into_iter().filter(|t| t.ends_with(" done")).collect::<Vec<_>>();
let edited = faces(&r.e);
assert_eq!(edited, vec!["\u{2612} done", "\u{2612} done", "\u{2610} done"]);
let bytes = crate::docx::write::save(r.e.document()).expect("saves");
if let Some(d) = std::env::var_os("RT_KEEP") {
std::fs::write(std::path::Path::new(&d).join("FX-REPEAT-REGION.checkbox.docx"), &bytes).expect("writes");
}
let again = super::super::Editor::open(&bytes, fonts()).expect("reopens");
assert_eq!(faces(&again), edited);
let boxes = again.document().controls.values().filter(|c| matches!(c.props.kind, crate::docx::ControlKind::Checkbox { .. })).count();
assert_eq!(boxes, 3);
}
fn body_floats(e: &super::super::Editor) -> Vec<crate::docx::Float> {
e.document()
.sections
.iter()
.flat_map(|s| s.blocks.iter())
.filter_map(|b| if let crate::docx::Block::Para(p) = b { Some(p) } else { None })
.flat_map(|p| p.inlines.iter())
.filter_map(|i| if let crate::docx::Inline::Float(f, _) = i { Some((**f).clone()) } else { None })
.collect()
}
fn body_pictures(e: &super::super::Editor) -> Vec<(f64, f64)> {
e.document()
.sections
.iter()
.flat_map(|s| s.blocks.iter())
.filter_map(|b| if let crate::docx::Block::Para(p) = b { Some(p) } else { None })
.flat_map(|p| p.inlines.iter())
.filter_map(|i| if let crate::docx::Inline::Picture(p, _) = i { Some((p.w, p.h)) } else { None })
.collect()
}
#[test]
fn wrapping_and_the_anchor_lock_save() {
use crate::editing::objects::ObjectCmd;
use crate::editing::selection::ObjectRef;
let mut e = open("FX-OBJ.docx");
let ids: Vec<crate::layout::FloatId> = body_floats(&e).iter().map(|f| crate::layout::FloatId(f.id)).collect();
let was = body_floats(&e);
e.set_caret_for_benchmark(Selection::Object(ObjectRef::Floating(ids[0])));
assert!(matches!(e.execute(Command::Object(ObjectCmd::Wrap("tight".into())), fonts()), super::super::Outcome::Changed(_)));
e.execute(Command::Object(ObjectCmd::LockAnchor), fonts());
e.set_caret_for_benchmark(Selection::Object(ObjectRef::Floating(ids[1])));
e.execute(Command::Object(ObjectCmd::Wrap("behind".into())), fonts());
e.set_caret_for_benchmark(Selection::Object(ObjectRef::Floating(ids[2])));
e.execute(Command::Object(ObjectCmd::Wrap("square".into())), fonts());
let now = body_floats(&e);
assert_eq!((now[0].wrap.as_str(), now[0].locked), ("tight", !was[0].locked));
assert_eq!((now[1].wrap.as_str(), now[1].behind), ("none", true));
let bytes = crate::docx::write::save(e.document()).expect("saves");
if let Some(d) = std::env::var_os("RT_KEEP") {
std::fs::write(std::path::Path::new(&d).join("FX-OBJ.wrap.docx"), &bytes).expect("writes");
}
let again = super::super::Editor::open(&bytes, fonts()).expect("reopens");
let back = body_floats(&again);
for (a, b) in back.iter().zip(&now) {
assert_eq!((&a.wrap, &a.wrap_text, a.behind, a.locked), (&b.wrap, &b.wrap_text, b.behind, b.locked));
}
assert_eq!(texts(&again), texts(&e));
for _ in 0..4 {
e.execute(Command::Undo, fonts());
}
let undone = body_floats(&e);
assert!(undone.iter().zip(&was).all(|(a, b)| a.wrap == b.wrap && a.behind == b.behind && a.locked == b.locked));
}
#[test]
fn an_inline_picture_resizes_by_its_handle_and_saves() {
use serde_json::json;
let mut r = super::scenarios::Run::open("FX-OBJ-LOCK.docx", "inline-resize");
let was = body_pictures(&r.e);
r.step(&json!({ "click": { "picture": 0 } }));
assert!(matches!(r.e.selection(), Selection::Object(crate::editing::selection::ObjectRef::Inline { .. })), "{:?}", r.e.selection());
r.step(&json!({ "drag": { "from": { "picture": 0, "at": "bottom-right" }, "to": { "picture": 0, "at": "bottom-right", "dx": 36.0 } } }));
let now = body_pictures(&r.e);
assert!(now[0].0 > was[0].0 + 30.0, "{was:?} -> {now:?}");
assert!((now[0].0 / now[0].1 - was[0].0 / was[0].1).abs() < 0.02, "ratio kept: {was:?} -> {now:?}");
assert_eq!(&now[1..], &was[1..]);
let bytes = crate::docx::write::save(r.e.document()).expect("saves");
if let Some(d) = std::env::var_os("RT_KEEP") {
std::fs::write(std::path::Path::new(&d).join("FX-OBJ.inline-resize.docx"), &bytes).expect("writes");
}
let again = super::super::Editor::open(&bytes, fonts()).expect("reopens");
let back = body_pictures(&again);
assert!(back.iter().zip(&now).all(|(a, b)| (a.0 - b.0).abs() < 0.01 && (a.1 - b.1).abs() < 0.01), "{back:?} vs {now:?}");
assert_eq!(texts(&again), texts(&r.e));
}
#[test]
fn reset_size_gives_a_picture_its_own_size() {
use crate::editing::objects::ObjectCmd;
use crate::editing::selection::ObjectRef;
let mut e = open("FX-OBJ.docx");
let (para, offset) = e
.index()
.para_ids()
.find_map(|id| {
let p = e.index().para(e.document(), id)?;
let mut at = 0u32;
p.inlines.iter().find_map(|i| {
let here = at;
at += i.units() as u32;
matches!(i, crate::docx::Inline::Picture(..)).then_some((id, Unit(here)))
})
})
.expect("an inline picture");
let pic = |e: &super::super::Editor| match e.index().para(e.document(), para).and_then(|p| super::super::stories::inline_at(p, offset)) {
Some(crate::docx::Inline::Picture(p, _)) => (p.w, p.h, p.image),
_ => panic!("no picture"),
};
e.set_caret_for_benchmark(Selection::Object(ObjectRef::Inline { para, offset }));
e.execute(Command::Object(ObjectCmd::Size(20.0, 10.0)), fonts());
assert_eq!((pic(&e).0, pic(&e).1), (20.0, 10.0));
e.execute(Command::Object(ObjectCmd::ResetSize), fonts());
let image = pic(&e).2;
let (w, h, dx, dy) = crate::docx::image::image_info(&e.document().images[image.expect("an image")]).expect("decodes");
let want = ((w * 72.0 / dx * 20.0).round() / 20.0, (h * 72.0 / dy * 20.0).round() / 20.0);
assert_eq!((pic(&e).0, pic(&e).1), want);
assert!(matches!(e.execute(Command::Object(ObjectCmd::Wrap("square".into())), fonts()), super::super::Outcome::Unchanged(_)));
}
#[test]
fn an_inserted_picture_saves_with_its_image() {
use crate::editing::selection::ObjectRef;
let data = open("FX-OBJ.docx").document().images.last().cloned().expect("an image");
let mut e = open("FX-STORIES.docx");
let out = e.execute(Command::InsertPicture(data.clone()), fonts());
assert!(matches!(out, super::super::Outcome::Changed(_)), "{out:?}");
assert!(matches!(e.selection(), Selection::Object(ObjectRef::Inline { .. })), "{:?}", e.selection());
let header = e.document().stories.keys().filter(|n| n.contains("header")).min().cloned().expect("a header");
let story = e.index().header_footer(&header).expect("its story");
let para =
e.index().para_ids().find(|id| e.index().para_slot(*id).is_some_and(|s| e.index().story(s.container) == story)).expect("a header paragraph");
e.set_caret_for_benchmark(Selection::Caret(super::super::position::Position::text(story, para, Unit(0))));
e.execute(Command::InsertPicture(data.clone()), fonts());
assert!(matches!(e.execute(Command::InsertPicture(b"not an image".to_vec()), fonts()), super::super::Outcome::Unchanged(_)));
let pictures = |e: &super::super::Editor| -> Vec<(f64, f64, Vec<u8>)> {
let d = e.document();
let mut out = Vec::new();
let paras = d.sections.iter().flat_map(|s| s.blocks.iter()).chain(d.stories.values().flatten());
for b in paras {
if let crate::docx::Block::Para(p) = b {
for i in &p.inlines {
if let crate::docx::Inline::Picture(pic, _) = i {
out.push((pic.w, pic.h, pic.image.map(|k| d.images[k].clone()).unwrap_or_default()));
}
}
}
}
out
};
let now = pictures(&e);
assert_eq!(now.len(), 2, "{:?}", now.iter().map(|p| (p.0, p.1)).collect::<Vec<_>>());
let bytes = crate::docx::write::save(e.document()).expect("saves");
if let Some(d) = std::env::var_os("RT_KEEP") {
std::fs::write(std::path::Path::new(&d).join("FX-STORIES.picture.docx"), &bytes).expect("writes");
}
let again = super::super::Editor::open(&bytes, fonts()).expect("reopens");
let back = pictures(&again);
assert_eq!(back.len(), 2);
for (a, b) in back.iter().zip(&now) {
assert!((a.0 - b.0).abs() < 0.01 && (a.1 - b.1).abs() < 0.01 && a.2 == b.2, "{:?} vs {:?}", (a.0, a.1), (b.0, b.1));
}
assert_eq!(texts(&again), texts(&e));
e.execute(Command::Undo, fonts());
e.execute(Command::Undo, fonts());
assert!(pictures(&e).is_empty());
}
#[test]
fn an_inserted_text_box_takes_typing_and_saves() {
let mut e = open("FX-TEXT.docx");
let before = body_floats(&e).len();
assert!(matches!(e.execute(Command::InsertTextBox, fonts()), super::super::Outcome::Changed(_)));
assert!(matches!(e.selection().focus().map(|p| p.story), Some(StoryId::TextBox(_))), "{:?}", e.selection());
e.execute(Command::InsertText("Boxed text".into()), fonts());
let box_text = |e: &super::super::Editor| -> Vec<String> {
body_floats(e)
.iter()
.filter_map(|f| f.text_box.as_ref())
.map(|tb| {
tb.blocks
.iter()
.filter_map(|b| if let crate::docx::Block::Para(p) = b { Some(p) } else { None })
.flat_map(|p| p.inlines.iter())
.filter_map(|i| if let crate::docx::Inline::Text(t, _) = i { Some(t.to_string()) } else { None })
.collect()
})
.collect()
};
assert_eq!(body_floats(&e).len(), before + 1);
assert_eq!(box_text(&e), vec!["Boxed text".to_string()]);
let bytes = crate::docx::write::save(e.document()).expect("saves");
if let Some(d) = std::env::var_os("RT_KEEP") {
std::fs::write(std::path::Path::new(&d).join("FX-TEXT.textbox.docx"), &bytes).expect("writes");
}
let again = super::super::Editor::open(&bytes, fonts()).expect("reopens");
assert_eq!(box_text(&again), box_text(&e));
let (a, b) = (&body_floats(&again)[before], &body_floats(&e)[before]);
assert!((a.w - b.w).abs() < 0.01 && (a.h - b.h).abs() < 0.01 && a.wrap == b.wrap && a.h_relative == b.h_relative, "{a:?}");
assert_eq!(texts(&again), texts(&e));
e.execute(Command::Undo, fonts());
e.execute(Command::Undo, fonts());
assert_eq!(body_floats(&e).len(), before);
}
fn same_lines(a: &[String], b: &[String]) {
if let Some(k) = (0..a.len().max(b.len())).find(|&k| a.get(k) != b.get(k)) {
panic!(
"line {k} differs:
got {:?}
want {:?}",
a.get(k),
b.get(k)
);
}
}
fn table_formatting(e: &super::super::Editor) -> Vec<String> {
fn walk(blocks: &[crate::docx::Block], out: &mut Vec<String>) {
for b in blocks {
if let crate::docx::Block::Table(t) = b {
out.push(format!("table {:?} {:?} {:?} {:?} {:?} grid {:?}", t.style_id, t.look, t.width, t.jc, (t.ind * 20.0).round(), t.grid));
for r in &t.rows {
out.push(format!(" row {:?} cant_split {}", r.height, r.cant_split));
for c in &r.cells {
out.push(format!(
" cell {:?} {:?} {:?} {:?} {:?} {:?} {:?} {}",
c.width, c.top, c.left, c.bottom, c.right, c.shading, c.margins, c.v_align
));
walk(&c.blocks, out);
}
}
}
}
}
let mut out = Vec::new();
for s in &e.document().sections {
walk(&s.blocks, &mut out);
}
out
}
#[test]
fn table_properties_save_and_reopen() {
use serde_json::json;
let mut r = super::scenarios::Run::open("FX-TBL-BASIC.docx", "table-properties-save");
let before = table_formatting(&r.e);
r.step(&json!({ "drag": { "from": { "para": 13, "offset": 2 }, "to": { "para": 17, "offset": 2 } } }));
let steps = [
("table.borders.outside", Some("double,1.5,C00000")),
("table.borders.inside-h", Some("dashed,0.75,0070C0")),
("table.cell-margins", Some("2,9,2,9")),
("table.row-height", Some("30,exact")),
("table.row-cant-split", None),
("table.column-width", Some("100")),
("table.align.center", None),
("table.indent", Some("18")),
];
for (k, (id, arg)) in steps.iter().enumerate() {
let undo = r.e.history().undo_depth();
r.step(&json!({ "command": id, "arg": arg }));
assert_eq!(r.e.history().undo_depth(), undo + 1, "{id}: one step");
let _ = k;
}
let edited = table_formatting(&r.e);
assert_ne!(edited, before);
let t =
r.e.document().sections[0].blocks.iter().filter_map(|b| if let crate::docx::Block::Table(t) = b { Some(t) } else { None }).nth(1).unwrap();
let top = t.rows.iter().position(|row| row.height.as_ref().is_some_and(|h| h.1 == "exact")).expect("a row made exact");
if top > 0 {
let above = &t.rows[top - 1].cells[1];
assert_eq!(above.bottom.as_ref().map(|b| b.style.as_str()), Some("double"), "{above:?}");
}
let bytes = crate::docx::write::save(r.e.document()).expect("saves");
if let Some(d) = std::env::var_os("RT_KEEP") {
std::fs::write(std::path::Path::new(&d).join("FX-TBL-BASIC.properties.docx"), &bytes).expect("writes");
}
let again = super::super::Editor::open(&bytes, fonts()).expect("reopens");
same_lines(&table_formatting(&again), &edited);
assert_eq!(texts(&again), texts(&r.e));
for _ in 0..steps.len() {
r.e.execute(Command::Undo, fonts());
}
same_lines(&table_formatting(&r.e), &before);
}
#[test]
fn a_table_style_and_its_options_restyle_the_cells() {
use serde_json::json;
let mut r = super::scenarios::Run::open("../tables.docx", "table-style-save");
let first_table = |e: &super::super::Editor| -> crate::docx::Table {
e.document()
.sections
.iter()
.flat_map(|s| s.blocks.iter())
.find_map(|b| if let crate::docx::Block::Table(t) = b { Some(t.clone()) } else { None })
.unwrap()
};
let t0 = first_table(&r.e);
assert_eq!(t0.style_id.as_deref(), Some("TableGrid"));
let para = r.e.document().sections[0].blocks.iter().take_while(|b| matches!(b, crate::docx::Block::Para(_))).count();
r.step(&json!({ "click": { "para": para, "offset": 0 } }));
r.step(&json!({ "command": "table.style", "arg": "GridTable4-Accent1" }));
let styled = first_table(&r.e);
assert_eq!(styled.style_id.as_deref(), Some("GridTable4-Accent1"));
let fill = |t: &crate::docx::Table, row: usize| t.rows[row].cells[0].shading;
assert_ne!(fill(&styled, 0), fill(&t0, 0), "the header row takes the style's fill");
r.step(&json!({ "command": "table.look", "arg": "banded-rows" }));
let plain = first_table(&r.e);
assert_ne!(fill(&plain, 0), fill(&styled, 0), "no header row: the first row is a band");
let edited = table_formatting(&r.e);
let bytes = crate::docx::write::save(r.e.document()).expect("saves");
if let Some(d) = std::env::var_os("RT_KEEP") {
std::fs::write(std::path::Path::new(&d).join("tables.style.docx"), &bytes).expect("writes");
}
let again = super::super::Editor::open(&bytes, fonts()).expect("reopens");
same_lines(&table_formatting(&again), &edited);
assert_eq!(texts(&again), texts(&r.e));
}
fn story_texts(e: &super::super::Editor) -> Vec<(String, String)> {
e.document()
.stories
.iter()
.map(|(n, b)| {
(
n.clone(),
b.iter()
.filter_map(|b| if let crate::docx::Block::Para(p) = b { Some(super::super::text::UnitText::of(p).text) } else { None })
.collect::<Vec<_>>()
.join("|"),
)
})
.collect()
}
#[test]
fn a_new_header_and_footer_save() {
let mut e = open("FX-TEXT.docx");
assert!(e.document().stories.is_empty());
assert!(matches!(e.execute(Command::EditHeaderFooter { footer: false }, fonts()), super::super::Outcome::Changed(_)));
assert!(matches!(e.selection().focus().map(|p| p.story), Some(StoryId::HeaderFooter(_))), "{:?}", e.selection());
type_text(&mut e, "New header");
e.execute(Command::CloseHeaderFooter, fonts());
assert!(matches!(e.selection().focus().map(|p| p.story), Some(StoryId::Body)));
e.execute(Command::EditHeaderFooter { footer: true }, fonts());
type_text(&mut e, "New footer");
e.execute(Command::EditHeaderFooter { footer: false }, fonts());
let edited = story_texts(&e);
assert_eq!(edited.iter().map(|(_, t)| t.as_str()).collect::<Vec<_>>(), vec!["New footer", "New header"], "{edited:?}");
let bytes = crate::docx::write::save(e.document()).expect("saves");
if let Some(d) = std::env::var_os("RT_KEEP") {
std::fs::write(std::path::Path::new(&d).join("FX-TEXT.header.docx"), &bytes).expect("writes");
}
let again = super::super::Editor::open(&bytes, fonts()).expect("reopens");
assert_eq!(story_texts(&again), edited);
assert!(again.document().sections[0].headers[0].is_some() && again.document().sections[0].footers[0].is_some());
assert_eq!(texts(&again), texts(&e));
for _ in 0..4 {
e.execute(Command::Undo, fonts());
}
assert!(e.document().stories.is_empty(), "{:?}", story_texts(&e));
}
#[test]
fn a_double_click_in_an_empty_header_area_makes_the_header() {
use serde_json::json;
let mut r = super::scenarios::Run::open("FX-PAGES.docx", "new-header-click");
r.step(&json!({ "click": { "para": 0, "offset": 0, "dy": -40 }, "clicks": 2 }));
assert!(matches!(r.e.selection().focus().map(|p| p.story), Some(StoryId::HeaderFooter(_))), "{:?}", r.e.selection());
assert_eq!(r.e.document().stories.len(), 1);
r.step(&json!({ "key": "Escape" }));
assert!(matches!(r.e.selection().focus().map(|p| p.story), Some(StoryId::Body)));
}
#[test]
fn new_lists_save_with_their_definitions() {
let labels = |e: &super::super::Editor| -> Vec<Option<String>> {
e.document()
.sections
.iter()
.flat_map(|s| s.blocks.iter())
.filter_map(|b| if let crate::docx::Block::Para(p) = b { Some(p.list.as_ref().map(|l| l.text.clone())) } else { None })
.collect()
};
for (fixture, name) in [("FX-TEXT.docx", "FX-TEXT.lists.docx"), ("FX-LISTS.docx", "FX-LISTS.lists.docx")] {
let mut e = open(fixture);
let first = super::super::tests::para_id(&e, 1);
e.set_caret_for_benchmark(Selection::Caret(super::super::position::Position::text(StoryId::Body, first, Unit(0))));
assert!(matches!(e.execute(Command::ToggleList { bullet: false }, fonts()), super::super::Outcome::Changed(_)), "{fixture}");
let last = super::super::tests::para_id(&e, e.document().sections[0].blocks.len() - 1);
e.set_caret_for_benchmark(Selection::Caret(super::super::position::Position::text(StoryId::Body, last, Unit(0))));
e.execute(Command::ToggleList { bullet: true }, fonts());
let edited = labels(&e);
assert_eq!(edited[1].as_deref(), Some("1."), "{fixture}: {edited:?}");
let bytes = crate::docx::write::save(e.document()).expect("saves");
if let Some(d) = std::env::var_os("RT_KEEP") {
std::fs::write(std::path::Path::new(&d).join(name), &bytes).expect("writes");
}
let again = super::super::Editor::open(&bytes, fonts()).expect("reopens");
assert_eq!(labels(&again), edited, "{fixture}");
assert_eq!(texts(&again), texts(&e));
}
}
#[test]
fn a_placeholder_activates_and_fills() {
use serde_json::json;
let mut r = super::scenarios::Run::open("FX-PLACEHOLDER.docx", "placeholder");
let st = crate::editing::inspect::inspect(&mut r.e, "geometry", true);
let pics = st["geometry"]["pictures"].as_array().cloned().unwrap_or_default();
let k = (0..pics.len())
.find(|&k| {
r.step(&json!({ "click": { "picture": k } }));
r.e.selected_placeholder().is_some()
})
.expect("a drawn placeholder");
let info = r.e.selected_placeholder().unwrap();
assert_eq!((info.key.as_str(), info.label.as_str()), ("Signature.Recipient Sig", "Recipient Sig"));
let pt = pics[k]["rect"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect::<Vec<_>>();
let (x, y) = ((pt[0] + pt[2]) / 2.0, (pt[1] + pt[3]) / 2.0);
let out = r.e.apply_json(&json!({ "type": "pointer", "kind": "down", "x": x, "y": y, "clicks": 1, "seq": 1000 }).to_string(), fonts());
assert!(out.contains("\"placeholder\"") && out.contains("Signature.Recipient Sig"), "{out}");
r.e.apply_json(&json!({ "type": "pointer", "kind": "up", "x": x, "y": y, "seq": 1001 }).to_string(), fonts());
let data = open("FX-OBJ.docx").document().images.last().cloned().expect("an image");
let out = r.e.execute(Command::FillPlaceholder { key: Some(info.key.clone()), data: data.clone() }, fonts());
assert!(matches!(out, super::super::Outcome::Changed(_)), "{out:?}");
assert!(r.e.selected_placeholder().is_none(), "filled: no longer a placeholder");
let filled: Vec<(f64, f64)> =
r.e.document()
.sections
.iter()
.flat_map(|s| s.blocks.iter())
.flat_map(|b| match b {
crate::docx::Block::Para(p) => vec![p.clone()],
crate::docx::Block::Table(t) => t
.rows
.iter()
.flat_map(|r| r.cells.iter())
.flat_map(|c| c.blocks.iter())
.filter_map(|b| if let crate::docx::Block::Para(p) = b { Some(p.clone()) } else { None })
.collect(),
})
.flat_map(|p| p.inlines.into_iter())
.filter_map(|i| {
if let crate::docx::Inline::Picture(p, _) = i {
(p.image.is_some_and(|k| r.e.document().filled.contains(&k))).then_some((p.w, p.h))
} else {
None
}
})
.collect();
assert!(!filled.is_empty());
let (w, h) = filled[0];
assert!((h - 39.65).abs() < 0.1 && w < 214.15, "fitted by height: {w} x {h}");
let bytes = crate::docx::write::save(r.e.document()).expect("saves");
if let Some(d) = std::env::var_os("RT_KEEP") {
std::fs::write(std::path::Path::new(&d).join("FX-PLACEHOLDER.filled.docx"), &bytes).expect("writes");
}
let xml = {
use std::io::Read;
let mut z = zip::ZipArchive::new(std::io::Cursor::new(&bytes[..])).unwrap();
let mut s = String::new();
z.by_name("word/document.xml").unwrap().read_to_string(&mut s).unwrap();
s
};
assert!(xml.contains("Placeholder:Signature.Recipient Sig") && xml.contains("Recipient Sig"), "tag and alias kept");
assert!(!xml.contains("showingPlcHdr"), "no longer showing its placeholder");
let again = super::super::Editor::open(&bytes, fonts()).expect("reopens");
assert!(again.document().images.contains(&data), "the filled image is saved");
assert!(again.document().placeholders.is_empty(), "read back as filled");
assert_eq!(texts(&again), texts(&r.e));
r.e.execute(Command::Undo, fonts());
assert!(r.e.document().filled.is_empty());
}
#[test]
fn the_host_sets_how_placeholders_look() {
use serde_json::json;
let mut r = super::scenarios::Run::open("FX-PLACEHOLDER.docx", "placeholder-look");
let views = |r: &mut super::scenarios::Run| {
let n = r.e.stack().pages.len();
r.e.view(0..n).placeholders
};
assert!(views(&mut r).is_empty(), "no look set: drawn as stored");
let icon = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75z"/><circle cx="20" cy="4" r="2" fill="#333333"/><rect x="0" y="0" width="24" height="24" fill="none"/></svg>"##;
let arg = json!({ "key": "Signature.Recipient Sig", "color": "#C00000", "label": "Sign here", "icon": icon }).to_string();
r.step(&json!({ "command": "view.placeholder-look", "arg": arg }));
let v = views(&mut r);
assert_eq!(v.len(), 1, "{v:?}");
assert_eq!((v[0].label.as_str(), v[0].color), ("Sign here", 0x40_C0_00_00));
let shapes = v[0].icon.as_ref().expect("an icon");
assert_eq!((shapes.view_box, shapes.paths.len(), shapes.paths[1].1), ([0.0, 0.0, 24.0, 24.0], 2, Some(0xFF33_3333)));
assert!(v[0].rect[2] - v[0].rect[0] > 200.0, "the placeholder's box: {:?}", v[0].rect);
r.step(&json!({ "command": "view.placeholder-look", "arg": json!({ "key": "Signature.Recipient Sig" }).to_string() }));
r.step(&json!({ "command": "view.placeholder-look", "arg": json!({ "key": "", "color": "#2000AA00" }).to_string() }));
let v = views(&mut r);
assert_eq!((v.len(), v[0].label.as_str(), v[0].color), (1, "Recipient Sig", 0x20_00_AA_00));
let data = open("FX-OBJ.docx").document().images.last().cloned().unwrap();
r.e.execute(Command::FillPlaceholder { key: Some("Signature.Recipient Sig".into()), data }, fonts());
assert!(views(&mut r).is_empty());
}
fn controls_by_tag(bytes: &[u8]) -> Vec<(String, String)> {
use std::io::Read;
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
let mut xml = String::new();
z.by_name("word/document.xml").unwrap().read_to_string(&mut xml).unwrap();
let dom = roxmltree::Document::parse(&xml).unwrap();
let w = "http://schemas.openxmlformats.org/wordprocessingml/2006/main";
dom.descendants()
.filter(|n| n.has_tag_name((w, "sdt")))
.map(|n| {
let tag = n.descendants().find(|t| t.has_tag_name((w, "tag"))).and_then(|t| t.attribute((w, "val"))).unwrap_or("").to_string();
let content = n.children().find(|c| c.has_tag_name((w, "sdtContent")));
let mut text = String::new();
for d in content.into_iter().flat_map(|c| c.descendants()) {
if d.has_tag_name((w, "t")) {
text.push_str(d.text().unwrap_or(""));
} else if d.has_tag_name((w, "p")) && !text.is_empty() {
text.push('|');
}
}
(tag, text)
})
.collect()
}
#[test]
fn content_controls_survive_edits() {
use serde_json::json;
let run = |steps: &[serde_json::Value]| -> Vec<(String, String)> {
let mut r = super::scenarios::Run::open("FX-CONTROLS.docx", "controls");
for s in steps {
r.step(s);
}
let bytes = crate::docx::write::save(r.e.document()).expect("saves");
let again = super::super::Editor::open(&bytes, fonts()).expect("reopens");
assert_eq!(texts(&again), texts(&r.e), "{steps:?}");
controls_by_tag(&bytes)
};
let at = |p: usize, o: serde_json::Value| json!({ "click": { "para": p, "offset": o } });
let cases: Vec<(&str, Vec<serde_json::Value>)> = vec![
("nothing", vec![]),
("type in the block control", vec![at(2, json!(3)), json!({ "type": "X" })]),
("Enter at the block control's end", vec![at(3, json!("end")), json!({ "key": "Enter" }), json!({ "type": "New" })]),
(
"delete a paragraph of the block control",
vec![at(2, json!(0)), json!({ "key": "ArrowDown", "mods": { "shift": true } }), json!({ "key": "Delete" })],
),
("type in the text control", vec![at(4, json!(8)), json!({ "type": "X" })]),
("Enter after the text control", vec![at(4, json!("end")), json!({ "key": "Enter" }), json!({ "type": "Next" })]),
("type in the cell control", vec![at(5, json!("end")), json!({ "key": "Tab" }), json!({ "key": "End" }), json!({ "type": "X" })]),
("insert a row below the cell control's row", vec![at(5, json!(0)), json!({ "command": "table.insert-below" })]),
("Enter inside the block control", vec![at(2, json!(3)), json!({ "key": "Enter" }), json!({ "type": "Mid" })]),
(
"delete across the block control's start",
vec![at(1, json!(3)), json!({ "key": "ArrowDown", "mods": { "shift": true } }), json!({ "key": "Delete" })],
),
("Backspace after the block control", vec![at(4, json!(0)), json!({ "key": "Backspace" })]),
("Backspace at the block control's start", vec![at(2, json!(0)), json!({ "key": "Backspace" })]),
(
"merge the cell control's row",
vec![
json!({ "drag": { "from": { "para": 5, "offset": 0 }, "to": { "para": 6, "offset": "end" } } }),
json!({ "command": "table.merge" }),
],
),
("insert a column right of the cell control", vec![at(6, json!(0)), json!({ "command": "table.insert-right" })]),
("insert a column left of the cell control", vec![at(6, json!(0)), json!({ "command": "table.insert-left" })]),
("insert a row above the cell control's row", vec![at(6, json!(0)), json!({ "command": "table.insert-above" })]),
("split the cell control", vec![at(6, json!(0)), json!({ "command": "table.split" })]),
("split the cell control into rows", vec![at(6, json!(0)), json!({ "command": "table.split-rows" })]),
(
"copy the name line to the end",
vec![
at(4, json!(0)),
json!({ "key": "End", "mods": { "shift": true } }),
json!({ "clipboard": "copy" }),
at(9, json!("end")),
json!({ "key": "Enter" }),
json!({ "clipboard": "paste" }),
],
),
(
"cut and undo the block control",
vec![
at(2, json!(0)),
json!({ "key": "ArrowDown", "mods": { "shift": true } }),
json!({ "key": "End", "mods": { "shift": true } }),
json!({ "clipboard": "cut" }),
json!({ "command": "edit.undo" }),
],
),
];
let mut report = Vec::new();
for (name, steps) in cases {
report.push(format!("{name}: {:?}", run(&steps)));
}
let report = report.join("\n");
eprintln!("{report}");
for line in report.lines() {
for tag in ["blk", "name", "cell"] {
let want = if tag == "name" && line.starts_with("copy the name line") { 2 } else { 1 };
assert_eq!(line.matches(&format!("(\"{tag}\"")).count(), want, "control {tag} lost or doubled: {line}");
}
}
}
#[test]
fn placeholders_cant_be_resized_or_moved() {
use serde_json::json;
let size = |e: &super::super::Editor| -> Vec<(f64, f64)> {
let d = e.document();
let mut v = Vec::new();
for s in &d.sections {
for b in &s.blocks {
let paras: Vec<&crate::docx::Paragraph> = match b {
crate::docx::Block::Para(p) => vec![p],
crate::docx::Block::Table(t) => t
.rows
.iter()
.flat_map(|r| r.cells.iter())
.flat_map(|c| c.blocks.iter())
.filter_map(|b| if let crate::docx::Block::Para(p) = b { Some(p) } else { None })
.collect(),
};
for p in paras {
for i in &p.inlines {
if let crate::docx::Inline::Picture(pic, _) = i
&& pic.image.is_some_and(|k| d.fixed.contains(&k))
{
v.push((pic.w, pic.h));
}
}
}
}
}
v
};
let check = |mut r: super::scenarios::Run| {
let st = crate::editing::inspect::inspect(&mut r.e, "geometry", true);
let pics = st["geometry"]["pictures"].as_array().cloned().unwrap_or_default();
let k = (0..pics.len())
.find(|&k| {
r.step(&json!({ "click": { "picture": k } }));
r.e.selected_placeholder().is_some() || matches!(r.e.view(0..1).selection, crate::editing::view::SelectionView::Object(_, false))
})
.expect("a fixed picture");
let crate::editing::view::SelectionView::Object(_, handles) = r.e.view(0..1).selection else { panic!("an object selected") };
assert!(!handles, "no handles");
let before = size(&r.e);
assert!(!before.is_empty());
let corner = json!({ "picture": k, "at": "bottom-right" });
let to = json!({ "picture": k, "at": "bottom-right", "dx": 40.0, "dy": 30.0 });
r.step(&json!({ "drag": { "from": corner, "to": to } }));
assert_eq!(size(&r.e), before, "a drag from the corner doesn't resize it");
r.step(&json!({ "click": { "picture": k } }));
let out = r.e.execute(Command::Object(crate::editing::objects::ObjectCmd::Size(20.0, 20.0)), fonts());
assert!(matches!(out, super::super::Outcome::Unchanged(_)), "{out:?}");
assert_eq!(size(&r.e), before);
r
};
let mut r = check(super::scenarios::Run::open("FX-PLACEHOLDER.docx", "fixed"));
let data = open("FX-OBJ.docx").document().images.last().cloned().expect("an image");
r.e.execute(Command::FillPlaceholder { key: Some("Signature.Recipient Sig".into()), data }, fonts());
let bytes = crate::docx::write::save(r.e.document()).expect("saves");
let again = super::super::Editor::open(&bytes, fonts()).expect("reopens");
assert!(!again.document().fixed.is_empty(), "filled controls stay fixed");
let mut o = super::scenarios::Run::open("FX-OBJ.docx", "not fixed");
assert!(o.e.document().fixed.is_empty());
let st = crate::editing::inspect::inspect(&mut o.e, "geometry", true);
assert!(!st["geometry"]["pictures"].as_array().is_none_or(|a| a.is_empty()));
o.step(&json!({ "click": { "picture": 0 } }));
assert!(matches!(o.e.view(0..1).selection, crate::editing::view::SelectionView::Object(_, true)));
}
#[test]
fn sdt_locked_picture_controls_cant_be_deleted() {
use serde_json::json;
let bytes = {
use std::io::{Read, Write};
let src = std::fs::read(std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/editor/FX-PLACEHOLDER.docx")).expect("fixture");
let mut z = zip::ZipArchive::new(std::io::Cursor::new(src)).unwrap();
let mut out = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
for i in 0..z.len() {
let mut f = z.by_index(i).unwrap();
let mut data = Vec::new();
f.read_to_end(&mut data).unwrap();
let name = f.name().unwrap().to_string();
if name == "word/document.xml" {
data = String::from_utf8(data).unwrap().replace("<w:picture />", "<w:lock w:val=\"sdtLocked\"/><w:picture />").into_bytes();
}
out.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
out.write_all(&data).unwrap();
}
out.finish().unwrap().into_inner()
};
let pictures = |e: &super::super::Editor| texts(e).iter().map(|t| t.matches('\u{FFFC}').count()).sum::<usize>();
let select_picture = |r: &mut super::scenarios::Run| {
let st = crate::editing::inspect::inspect(&mut r.e, "geometry", true);
let n = st["geometry"]["pictures"].as_array().map_or(0, |a| a.len());
(0..n)
.find(|&k| {
r.step(&json!({ "click": { "picture": k } }));
r.e.selected_placeholder().is_some()
})
.expect("the placeholder")
};
let mut r = super::scenarios::Run::from_bytes(&bytes, "undeletable");
assert!(!r.e.document().undeletable.is_empty(), "read as undeletable");
let before = texts(&r.e);
let n = pictures(&r.e);
let cases: Vec<(&str, Vec<serde_json::Value>)> = vec![
("Delete on it", vec![json!({ "key": "Delete" })]),
("Backspace on it", vec![json!({ "key": "Backspace" })]),
("Select All, Delete", vec![json!({ "command": "edit.select_all" }), json!({ "key": "Delete" })]),
("Select All, type", vec![json!({ "command": "edit.select_all" }), json!({ "type": "x" })]),
("Select All, Cut", vec![json!({ "command": "edit.select_all" }), json!({ "command": "edit.cut" })]),
];
for (name, steps) in cases {
select_picture(&mut r);
for s in &steps {
r.step(s);
}
assert_eq!(texts(&r.e), before, "{name}");
}
assert_eq!(pictures(&r.e), n);
r.step(&json!({ "click": { "para": 0, "offset": 0 } }));
r.step(&json!({ "type": "Q" }));
assert_ne!(texts(&r.e), before);
let mut o = super::scenarios::Run::open("FX-PLACEHOLDER.docx", "deletable");
let m = pictures(&o.e);
select_picture(&mut o);
o.step(&json!({ "key": "Delete" }));
assert_eq!(pictures(&o.e), m - 1);
}
#[test]
fn alt_text_saves() {
use serde_json::json;
let mut r = super::scenarios::Run::open("FX-OBJ.docx", "alt-text");
r.step(&json!({ "click": { "picture": 0 } }));
let out = r.e.execute(Command::Object(crate::editing::objects::ObjectCmd::AltText("The board seal".into())), fonts());
assert!(matches!(out, super::super::Outcome::Changed(_)), "{out:?}");
let alts = |d: &crate::docx::Document| -> Vec<String> {
d.sections
.iter()
.flat_map(|s| s.blocks.iter())
.filter_map(|b| if let crate::docx::Block::Para(p) = b { Some(p) } else { None })
.flat_map(|p| p.inlines.iter())
.filter_map(|i| if let crate::docx::Inline::Picture(p, _) = i { Some(p.alt.to_string()) } else { None })
.collect()
};
assert!(alts(r.e.document()).contains(&"The board seal".to_string()));
let bytes = crate::docx::write::save(r.e.document()).expect("saves");
let again = super::super::Editor::open(&bytes, fonts()).expect("reopens");
assert_eq!(alts(again.document()), alts(r.e.document()));
assert_eq!(texts(&again), texts(&r.e));
r.e.execute(Command::Undo, fonts());
assert!(!alts(r.e.document()).contains(&"The board seal".to_string()));
}
#[test]
fn extend_mode_grows_and_extends() {
use serde_json::json;
let mut e = super::super::Editor::open(&blank_with("The quick brown fox jumps. Over the lazy dog.\nSecond paragraph here."), fonts()).unwrap();
let sel = |e: &mut super::super::Editor| e.selected_text();
e.execute(Command::Move { by: crate::editing::commands::MoveBy::Story, dir: crate::editing::commands::Dir::Back, extend: false }, fonts());
for _ in 0..6 {
e.execute(Command::Move { by: crate::editing::commands::MoveBy::Char, dir: crate::editing::commands::Dir::Forward, extend: false }, fonts());
}
assert_eq!(e.execute(Command::ExtendMode, fonts()), super::super::Outcome::Moved);
assert_eq!(sel(&mut e), "", "the first F8 only turns the mode on");
e.execute(Command::ExtendMode, fonts());
assert_eq!(sel(&mut e).trim_end(), "quick");
e.execute(Command::ExtendMode, fonts());
assert_eq!(sel(&mut e).trim_end(), "The quick brown fox jumps.");
e.execute(Command::ExtendMode, fonts());
assert!(sel(&mut e).starts_with("The quick brown fox jumps. Over the lazy dog."));
e.execute(Command::ExtendMode, fonts());
assert!(sel(&mut e).contains("Second paragraph here."), "the document");
e.execute(Command::Escape, fonts());
assert!(!e.selection().is_collapsed());
e.execute(Command::Move { by: crate::editing::commands::MoveBy::Char, dir: crate::editing::commands::Dir::Back, extend: false }, fonts());
assert!(e.selection().is_collapsed());
e.execute(Command::ExtendMode, fonts());
for _ in 0..3 {
e.execute(Command::Move { by: crate::editing::commands::MoveBy::Char, dir: crate::editing::commands::Dir::Forward, extend: false }, fonts());
}
assert_eq!(sel(&mut e), "The");
e.execute(Command::InsertText("b".into()), fonts());
assert_eq!(sel(&mut e), "The quick b");
e.execute(Command::InsertText("Q".into()), fonts());
assert_eq!(sel(&mut e), "The quick b", "no capital Q after it");
e.execute(Command::Move { by: crate::editing::commands::MoveBy::Char, dir: crate::editing::commands::Dir::Forward, extend: false }, fonts());
assert_eq!(sel(&mut e), "The quick br", "still extending");
let _ = json!(null);
}
#[test]
fn block_selection_selects_copies_formats_and_deletes() {
use crate::editing::commands::{Dir, MoveBy};
let text = "abcdefgh\nijklmnop\nqrstuvwx\nyz";
let mut e = super::super::Editor::open(&super::blank_in(text, "Courier New"), fonts()).unwrap();
let mv = |e: &mut super::super::Editor, by, dir| {
e.execute(Command::Move { by, dir, extend: false }, fonts());
};
mv(&mut e, MoveBy::Story, Dir::Back);
mv(&mut e, MoveBy::Char, Dir::Forward);
mv(&mut e, MoveBy::Char, Dir::Forward);
e.execute(Command::BlockSelect, fonts());
mv(&mut e, MoveBy::Line, Dir::Forward);
mv(&mut e, MoveBy::Line, Dir::Forward);
mv(&mut e, MoveBy::Char, Dir::Forward);
mv(&mut e, MoveBy::Char, Dir::Forward);
mv(&mut e, MoveBy::Char, Dir::Forward);
assert!(matches!(e.selection(), crate::editing::selection::Selection::Block { .. }), "{:?}", e.selection());
assert_eq!(e.selected_text(), "cde\nklm\nstu");
e.execute(Command::Toggle(crate::editing::format::Toggle::Bold), fonts());
let bold: Vec<String> = texts(&e)
.iter()
.enumerate()
.map(|(k, _)| {
let p = match &e.document().sections[0].blocks[k] {
crate::docx::Block::Para(p) => p,
_ => panic!(),
};
p.inlines
.iter()
.filter_map(|i| if let crate::docx::Inline::Text(t, r) = i { r.bold.then(|| t.clone()) } else { None })
.collect::<String>()
})
.collect();
assert_eq!(bold, ["cde", "klm", "stu", ""], "only the block is bold");
let before = texts(&e);
e.execute(Command::Delete { dir: Dir::Forward, word: false }, fonts());
assert_eq!(texts(&e), ["abfgh", "ijnop", "qrvwx", "yz"]);
e.execute(Command::Undo, fonts());
assert_eq!(texts(&e), before);
e.execute(Command::BlockSelect, fonts());
mv(&mut e, MoveBy::Line, Dir::Forward);
mv(&mut e, MoveBy::Char, Dir::Forward);
assert!(matches!(e.selection(), crate::editing::selection::Selection::Block { .. }));
e.execute(Command::Escape, fonts());
assert!(e.selection().is_collapsed());
}
#[test]
fn go_back_returns_to_earlier_edits() {
use crate::editing::commands::{Dir, MoveBy};
let mut e = super::super::Editor::open(&blank_with("First paragraph.\nSecond paragraph.\nThird paragraph."), fonts()).unwrap();
let mv = |e: &mut super::super::Editor, by, dir| {
e.execute(Command::Move { by, dir, extend: false }, fonts());
};
let para_no = |e: &super::super::Editor| {
let (p, u) = e.selection().focus().and_then(|p| p.para_offset()).unwrap();
let k = e.document().sections[0].blocks.iter().position(|b| matches!(b, crate::docx::Block::Para(q) if q.id == p.0)).unwrap();
(k, u.0)
};
mv(&mut e, MoveBy::Story, Dir::Back);
mv(&mut e, MoveBy::LineEdge, Dir::Forward);
e.execute(Command::InsertText("!".into()), fonts());
mv(&mut e, MoveBy::Story, Dir::Forward);
mv(&mut e, MoveBy::LineEdge, Dir::Back);
e.execute(Command::InsertText("> ".into()), fonts());
mv(&mut e, MoveBy::Para, Dir::Back);
mv(&mut e, MoveBy::Para, Dir::Back);
assert_eq!(para_no(&e).0, 1);
e.execute(Command::GoBack, fonts());
assert_eq!(para_no(&e), (2, 2), "the last edit");
e.execute(Command::GoBack, fonts());
assert_eq!(para_no(&e), (0, 17), "the one before");
e.execute(Command::GoBack, fonts());
assert_eq!(para_no(&e), (1, 0), "then where the caret was");
e.execute(Command::GoBack, fonts());
assert_eq!(para_no(&e), (2, 2), "round again");
mv(&mut e, MoveBy::Story, Dir::Back);
e.execute(Command::InsertText("123".into()), fonts());
mv(&mut e, MoveBy::Para, Dir::Forward);
e.execute(Command::GoBack, fonts());
assert_eq!(para_no(&e), (0, 3), "the newest edit");
e.execute(Command::GoBack, fonts());
assert_eq!(para_no(&e), (2, 2));
e.execute(Command::GoBack, fonts());
assert_eq!(para_no(&e), (0, 20), "mapped through the insertion before it");
}
#[test]
fn drag_and_drop_moves_and_copies_text() {
use serde_json::json;
let doc = blank_with("The quick brown fox.\nJumps over.");
let select_quick = |r: &mut super::scenarios::Run| {
r.step(&json!({ "click": { "para": 0, "offset": 4 } }));
for _ in 0..5 {
r.step(&json!({ "key": "ArrowRight", "mods": { "shift": true } }));
}
assert_eq!(r.e.selected_text(), "quick");
};
let mut r = super::scenarios::Run::from_bytes(&doc, "dnd-click");
select_quick(&mut r);
r.step(&json!({ "click": { "para": 0, "offset": 6 } }));
assert!(r.e.selection().is_collapsed());
let mut r = super::scenarios::Run::from_bytes(&doc, "dnd-move");
select_quick(&mut r);
let before = texts(&r.e);
r.step(&json!({ "drag": { "from": { "para": 0, "offset": 6 }, "to": { "para": 1, "offset": "end" } } }));
let t = texts(&r.e);
assert_eq!(t[0], "The brown fox.", "no doubled space left (smart cut)");
assert!(t[1].contains("quick"), "{t:?}");
assert_eq!(r.e.selected_text().trim(), "quick");
r.e.execute(Command::Undo, fonts());
assert_eq!(texts(&r.e), before, "one undo step");
let mut r = super::scenarios::Run::from_bytes(&doc, "dnd-copy");
select_quick(&mut r);
r.step(&json!({ "drag": { "from": { "para": 0, "offset": 6 }, "to": { "para": 1, "offset": 0 } }, "mods": { "ctrl": true } }));
let t = texts(&r.e);
assert!(t[0].contains("quick") && t[1].contains("quick"), "{t:?}");
let mut r = super::scenarios::Run::from_bytes(&doc, "dnd-self");
select_quick(&mut r);
let before = texts(&r.e);
r.step(&json!({ "drag": { "from": { "para": 0, "offset": 5 }, "to": { "para": 0, "offset": 8 } } }));
assert_eq!(texts(&r.e), before);
}
fn typed_table(cells: &[&[&str]]) -> super::super::Editor {
let mut e = super::super::Editor::open(&blank_with(""), fonts()).unwrap();
e.execute(Command::InsertTable { rows: cells.len(), cols: cells[0].len() }, fonts());
for (r, row) in cells.iter().enumerate() {
for (c, t) in row.iter().enumerate() {
e.execute(Command::InsertText((*t).into()), fonts());
if r + 1 < cells.len() || c + 1 < row.len() {
e.execute(Command::NextCell { back: false }, fonts());
}
}
}
e
}
fn table_texts(e: &super::super::Editor) -> Vec<Vec<String>> {
let t = e.document().sections[0].blocks.iter().find_map(|b| if let crate::docx::Block::Table(t) = b { Some(t) } else { None }).unwrap();
t.rows
.iter()
.map(|r| {
r.cells
.iter()
.map(|c| {
c.blocks
.iter()
.filter_map(|b| if let crate::docx::Block::Para(p) = b { Some(crate::docx::fields::text(p)) } else { None })
.collect::<String>()
})
.collect()
})
.collect()
}
#[test]
fn rows_move_up_and_down() {
use crate::editing::keymap::Mods;
let mut e = typed_table(&[&["a1", "a2"], &["b1", "b2"], &["c1", "c2"]]);
let up = e.key_command("ArrowUp", Mods { alt: true, shift: true, ..Mods::default() }).unwrap();
e.execute(up, fonts());
assert_eq!(table_texts(&e), [["a1", "a2"], ["c1", "c2"], ["b1", "b2"]]);
e.execute(Command::MoveRows { up: true }, fonts());
assert_eq!(table_texts(&e), [["c1", "c2"], ["a1", "a2"], ["b1", "b2"]]);
assert!(matches!(e.execute(Command::MoveRows { up: true }, fonts()), super::super::Outcome::Unchanged(_)), "at the top");
assert!(matches!(e.selection(), Selection::Cells { anchor: crate::editing::selection::CellRef { row: 0, .. }, .. }), "the moved row is selected");
e.execute(Command::Undo, fonts());
assert_eq!(table_texts(&e), [["a1", "a2"], ["c1", "c2"], ["b1", "b2"]]);
let saved = crate::docx::write::save(e.document()).unwrap();
assert_eq!(table_texts(&super::super::Editor::open(&saved, fonts()).unwrap()), table_texts(&e), "saved in the new order");
}
#[test]
fn double_click_auto_fits_a_column() {
let mut e = typed_table(&[
&["h1", "h2", "h3"],
&["A longer piece of text in column one that wraps onto a second line here.", "short", "medium length text"],
&["A longer piece of text in column one that wraps onto a second line here.", "short", "medium length text"],
]);
let grid = |e: &super::super::Editor| {
e.document().sections[0].blocks.iter().find_map(|b| if let crate::docx::Block::Table(t) = b { Some(t.grid.clone()) } else { None }).unwrap()
};
let table = e
.index()
.para_ids()
.find_map(|p| {
e.index()
.para_slot(p)
.and_then(|s| if let crate::editing::index::Container::Cell { table, .. } = s.container { Some(table) } else { None })
})
.unwrap();
let before = grid(&e);
e.execute(Command::AutoFitColumn { table, col: 1 }, fonts());
let g = grid(&e);
assert!(g[1] < before[1] / 2.0 && g[1] > 20.0, "'short' fits: {g:?}");
assert_eq!((g[0], g[2]), (before[0], before[2]), "the others keep their widths");
e.execute(Command::AutoFitColumn { table, col: 0 }, fonts());
let g2 = grid(&e);
assert!(g2[0] > g[0], "the long text widens its column: {g2:?}");
assert!(g2.iter().sum::<f64>() <= 468.0 + 12.0, "at most the text width and the padding: {g2:?}");
}
#[test]
fn ctrl_alt_5_and_tab_go_through_floats() {
use crate::editing::keymap::Mods;
let mut e = open("FX-OBJ.docx");
let five = e.key_command("Digit5", Mods { ctrl: true, alt: true, ..Mods::default() }).unwrap();
e.execute(five, fonts());
let first = e.selection();
assert!(matches!(first, Selection::Object(crate::editing::selection::ObjectRef::Floating(_))), "{first:?}");
let tab = e.key_command("Tab", Mods::default()).unwrap();
e.execute(tab, fonts());
let second = e.selection();
assert!(matches!(second, Selection::Object(crate::editing::selection::ObjectRef::Floating(_))));
let back = e.key_command("Tab", Mods { shift: true, ..Mods::default() }).unwrap();
e.execute(back, fonts());
assert_eq!(e.selection(), first);
e.execute(Command::Escape, fonts());
assert!(e.selection().is_collapsed(), "Esc returns to the text");
}
#[test]
fn the_caret_shows_after_the_document_gets_shorter_than_its_last_page() {
let mut e = open("FX-TEXT.docx");
e.execute(Command::Move { by: MoveBy::Story, dir: Dir::Forward, extend: false }, fonts());
e.complete(fonts());
assert!(e.caret_view().expect("caret").page > 0, "FX-TEXT is longer than a page");
e.execute(Command::SelectAll, fonts());
e.execute(Command::InsertText("x".into()), fonts());
e.complete(fonts());
assert_eq!(e.snapshot().pages().len(), 1);
assert!(e.caret_view().is_some(), "the caret shows");
}