Ely Office: six offline-first desktop apps in Rust and GPUI

ElyDoc, ElyTable, ElyShow (LibreOfficeKit), ElyPDF (MuPDF), ElyTree (Git),
ElyMark (Milkdown and CodeMirror in CEF). ElyMail and ElyAgent follow.
This commit is contained in:
ZacharyZhang-NY committed 2026-10-08 21:20:08 -04:00
commit b7fca0146d
817 files changed
+143101

No files matched your search

+333
View File
@@ -0,0 +1,333 @@
use super::rules::row;
use super::sheet::open_book;
use super::*;
use crate::sheet::{self, Layout, Look};
/// Region, Year, Sales; four rows.
fn data() -> String {
let rows: String = [
["Region", "Year", "Sales"],
["East", "2025", "3"],
["West", "2025", "1"],
["East", "2026", "4"],
["West", "2026", "2"],
]
.iter()
.map(|cells| row(cells))
.collect();
format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<office:document xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0"
xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0"
xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0" office:version="1.3" office:mimetype="application/vnd.oasis.opendocument.spreadsheet">
<office:body><office:spreadsheet><table:table table:name="S">{rows}</table:table></office:spreadsheet></office:body>
</office:document>"#
)
}
pub(super) fn cells(worker: &mut Worker, doc: &Doc, range: &str) -> String {
worker.run(doc, "cell.goto", &[range]).expect("the range");
doc.selection_text(worker).expect("the text")
}
/// `range` as the kernel writes it.
fn written(worker: &mut Worker, doc: &Doc, range: &str) -> String {
worker.run(doc, "cell.goto", &[range]).expect("the range");
let read = worker.values(doc, sheet::SELECTED).expect("the selection");
sheet::selected_range(&read)
.expect("parses")
.expect("one range")
.range
}
fn pivot_here(worker: &mut Worker, doc: &Doc) -> Option<sheet::Pivot> {
let read = worker.values(doc, sheet::PIVOT).expect("the pivot read");
sheet::pivot(&read).expect("parses")
}
fn chart_here(worker: &mut Worker, doc: &Doc) -> Option<sheet::Chart> {
let read = worker.values(doc, sheet::CHART).expect("the chart read");
sheet::chart(&read).expect("parses")
}
/// Undo steps in the kernel's history.
pub(super) fn steps(worker: &mut Worker, doc: &Doc) -> usize {
let json = worker.values(doc, ".uno:Undo").expect("the history");
let value: serde_json::Value = serde_json::from_str(&json).expect("parses");
value["actions"].as_array().map_or(0, Vec::len)
}
pub(super) fn skip() -> bool {
let missing = install().is_none();
if missing {
eprintln!("SKIP: no pinned LOK build");
}
missing
}
#[test]
fn pivot_tables_make_change_and_refresh() {
if skip() {
return;
}
let (_dir, mut worker, doc) = open_book(&data());
let (source, dest, other) = (
written(&mut worker, &doc, "A1:C5"),
written(&mut worker, &doc, "E1"),
written(&mut worker, &doc, "K1"),
);
worker
.run(&doc, "cell.goto", &["A1:C7"])
.expect("past the data");
let read = worker.values(&doc, sheet::SELECTED).expect("the selection");
let wide = sheet::selected_range(&read).expect("parses").expect("one");
assert_eq!(wide.data, source, "the data inside, without empty rows");
worker.run(&doc, "cell.goto", &["E1"]).expect("E1");
assert_eq!(pivot_here(&mut worker, &doc), None, "no pivot yet");
let layout = Layout {
rows: vec!["Region".into()],
data: vec![("Sales".into(), "sum".into())],
..Layout::default()
};
let spec = layout.to_json(&source, &dest);
worker.run(&doc, "pivot.make", &[&spec]).expect("makes");
let made = cells(&mut worker, &doc, "E1:F4");
assert!(
made.contains("East\t7") && made.contains("West\t3"),
"{made:?}"
);
worker.run(&doc, "cell.goto", &["E2"]).expect("on it");
let here = pivot_here(&mut worker, &doc).expect("a pivot");
assert_eq!(here.source, source);
assert_eq!(here.fields, ["Region", "Year", "Sales"]);
// The kernel places the data layout field in the columns.
let placed = Layout {
columns: vec![String::new()],
..layout.clone()
};
assert_eq!(here.layout, placed);
let changed = Layout {
columns: vec!["Year".into(), String::new()],
data: vec![("Sales".into(), "max".into())],
..placed
};
worker
.run(&doc, "pivot.make", &[&changed.to_json("", "")])
.expect("changes");
worker.run(&doc, "cell.goto", &["E2"]).expect("on it");
let here = pivot_here(&mut worker, &doc).expect("still a pivot");
assert_eq!(here.layout, changed, "fields and function changed in place");
worker.run(&doc, "cell.goto", &["C2"]).expect("C2");
worker.run(&doc, "cell.enter", &["9"]).expect("enters");
worker.run(&doc, "cell.goto", &["E3"]).expect("on it");
worker.run(&doc, "pivot.refresh", &[]).expect("refreshes");
let fresh = cells(&mut worker, &doc, "E1:H5");
assert!(
fresh.contains("East\t9\t4"),
"the new value shows: {fresh:?}"
);
let bad = Layout {
data: vec![("Nope".into(), "sum".into())],
..Layout::default()
};
worker.run(&doc, "cell.goto", &["K1"]).expect("K1");
assert!(
worker
.run(&doc, "pivot.make", &[&bad.to_json(&source, &other)])
.is_err(),
"an unknown field fails"
);
}
#[test]
fn charts_insert_and_change_in_one_step() {
if skip() {
return;
}
let (_dir, mut worker, doc) = open_book(&data());
let (all, part) = (
written(&mut worker, &doc, "A1:C5"),
written(&mut worker, &doc, "A1:C3"),
);
worker.run(&doc, "cell.goto", &["A1"]).expect("A1");
assert_eq!(chart_here(&mut worker, &doc), None, "no chart yet");
let look = Look {
kind: "column".into(),
title: "Sales".into(),
legend: true,
x_title: "Region".into(),
y_title: "Units".into(),
};
worker
.run(&doc, "chart.insert", &[&all, "false", &look.to_json()])
.expect("inserts");
let chart = chart_here(&mut worker, &doc).expect("the new chart is selected");
assert_eq!((&chart.look, &chart.range), (&look, &all));
let pie = Look {
kind: "pie".into(),
title: String::new(),
legend: false,
..Look::default()
};
worker
.run(
&doc,
"chart.change",
&[&chart.name, &part, "true", "true", &pie.to_json()],
)
.expect("changes");
let changed = chart_here(&mut worker, &doc).expect("still selected");
assert_eq!(changed.look, pie);
assert_eq!(changed.range, part);
worker.run(&doc, "edit.undo", &[]).expect("one undo");
let back = chart_here(&mut worker, &doc).expect("still selected");
assert_eq!((back.look, back.range), (look.clone(), chart.range.clone()));
let before = steps(&mut worker, &doc);
for bad in ["bad range", "NoSheet!A1:B3"] {
let change = [chart.name.as_str(), bad, "true", "true", &pie.to_json()];
assert!(
worker.run(&doc, "chart.change", &change).is_err(),
"{bad} changes nothing"
);
worker.run(&doc, "cell.goto", &["A1"]).expect("A1");
let insert = [bad, "false", &look.to_json()];
assert!(
worker.run(&doc, "chart.insert", &insert).is_err(),
"{bad} inserts nothing"
);
}
assert_eq!(steps(&mut worker, &doc), before, "nothing changed");
let wrong = Look {
kind: "donut".into(),
..pie
};
assert!(
worker
.run(
&doc,
"chart.change",
&[&chart.name, &part, "true", "true", &wrong.to_json()],
)
.is_err(),
"an unknown kind fails"
);
}
#[test]
fn a_read_layout_applies_back_unchanged() {
if skip() {
return;
}
let (_dir, mut worker, doc) = open_book(&data());
let (source, dest) = (
written(&mut worker, &doc, "A1:C5"),
written(&mut worker, &doc, "E1"),
);
let across = Layout {
rows: vec!["Region".into()],
columns: vec![String::new()],
data: vec![
("Sales".into(), "sum".into()),
("Year".into(), "max".into()),
],
..Layout::default()
};
worker
.run(&doc, "pivot.make", &[&across.to_json(&source, &dest)])
.expect("makes");
let before = cells(&mut worker, &doc, "E1:G4");
worker.run(&doc, "cell.goto", &["E2"]).expect("on it");
let read = pivot_here(&mut worker, &doc).expect("a pivot").layout;
assert_eq!(read, across, "the data field reads in place");
worker
.run(&doc, "pivot.make", &[&read.to_json("", "")])
.expect("applies");
assert_eq!(cells(&mut worker, &doc, "E1:G4"), before, "nothing moved");
}
/// The data with a pivot table that sums Region's subtotals.
fn with_subtotals() -> String {
let table = r#"<table:data-pilot-tables><table:data-pilot-table table:name="P" table:target-range-address="S.E1:G14"><table:source-cell-range table:cell-range-address="S.A1:C5"/><table:data-pilot-field table:source-field-name="Region" table:orientation="row"><table:data-pilot-level table:show-empty="false"><table:data-pilot-subtotals><table:data-pilot-subtotal table:function="sum"/></table:data-pilot-subtotals></table:data-pilot-level></table:data-pilot-field><table:data-pilot-field table:source-field-name="Year" table:orientation="row"><table:data-pilot-level table:show-empty="false"/></table:data-pilot-field><table:data-pilot-field table:source-field-name="Sales" table:orientation="data" table:function="sum"/><table:data-pilot-field table:source-field-name="" table:is-data-layout-field="true" table:orientation="column"/></table:data-pilot-table></table:data-pilot-tables>"#;
data().replace(
"</office:spreadsheet>",
&format!("{table}</office:spreadsheet>"),
)
}
#[test]
fn a_read_layout_keeps_subtotals() {
if skip() {
return;
}
let (_dir, mut worker, doc) = open_book(&with_subtotals());
worker.run(&doc, "cell.goto", &["E3"]).expect("on it");
worker.run(&doc, "pivot.refresh", &[]).expect("refreshes");
let before = cells(&mut worker, &doc, "E1:G14");
assert!(
before.contains("East Sum - Sales"),
"the subtotal shows: {before:?}"
);
worker.run(&doc, "cell.goto", &["E3"]).expect("on it");
let read = pivot_here(&mut worker, &doc).expect("a pivot").layout;
worker
.run(&doc, "pivot.make", &[&read.to_json("", "")])
.expect("applies");
assert_eq!(cells(&mut worker, &doc, "E1:G14"), before, "nothing moved");
}
/// The book saved as flat XML, read back as text.
fn saved(worker: &mut Worker, doc: &Doc, path: &std::path::Path) -> String {
doc.save_as(worker, &crate::file_url(path), "fods")
.expect("saves");
std::fs::read_to_string(path).expect("reads")
}
#[test]
fn undoing_a_new_kind_brings_the_whole_chart_back() {
if skip() {
return;
}
let (dir, mut worker, doc) = open_book(&data());
let all = written(&mut worker, &doc, "A1:C5");
let look = Look {
kind: "column".into(),
legend: true,
..Look::default()
};
worker
.run(&doc, "chart.insert", &[&all, "false", &look.to_json()])
.expect("inserts");
let name = chart_here(&mut worker, &doc).expect("selected").name;
let flat = dir.path().join("stacked.fods");
let text = saved(&mut worker, &doc, &flat);
// The plot area's style gains stacking.
let plot = r#"chart:include-hidden-cells="false""#;
assert!(text.contains(plot), "the plot area style");
let stacked = format!(r#"chart:stacked="true" {plot}"#);
std::fs::write(&flat, text.replace(plot, &stacked)).expect("stacks it");
worker.close_doc(doc).expect("closes");
let doc = worker.load(&crate::file_url(&flat)).expect("reloads");
let before = saved(&mut worker, &doc, &dir.path().join("before.fods"));
assert!(
before.contains(r#"chart:stacked="true""#),
"the kernel reads it stacked"
);
let pie = Look {
kind: "pie".into(),
..look
};
let change = [name.as_str(), &all, "true", "true", &pie.to_json()];
worker.run(&doc, "chart.change", &change).expect("changes");
worker.run(&doc, "edit.undo", &[]).expect("one undo");
let back = saved(&mut worker, &doc, &dir.path().join("back.fods"));
assert!(
back.contains(r#"chart:stacked="true""#),
"the stacking came back"
);
}
+213
View File
@@ -0,0 +1,213 @@
use super::analysis::steps;
use super::show::open_deck;
use super::*;
use crate::show::{self, ChartData};
fn chart(worker: &mut Worker, doc: &Doc) -> show::SlideChart {
let read = worker.values(doc, crate::sheet::CHART).expect("reads");
show::slide_chart(&read).expect("parses").expect("a chart")
}
fn select_chart(worker: &mut Worker, doc: &Doc) {
let objects =
show::objects(&worker.values(doc, show::OBJECTS).expect("reads")).expect("parses");
let at = (objects.objects.iter())
.position(|object| object.kind == "chart")
.expect("a chart");
let state = worker.now(doc).expect("the state");
worker
.run(&state, "object.select", &[&at.to_string(), "false"])
.expect("selects");
}
fn look(kind: &str) -> crate::sheet::Look {
crate::sheet::Look {
kind: kind.into(),
legend: true,
..Default::default()
}
}
/// Saves `doc` as PPTX, reopens it and selects its chart.
fn round_trip(worker: &mut Worker, doc: &Doc, path: &std::path::Path) -> Doc {
doc.save_as(worker, &crate::file_url(path), "pptx")
.expect("saves");
let deck = worker.load(&crate::file_url(path)).expect("loads");
select_chart(worker, &deck);
deck
}
#[test]
fn a_scatter_keeps_editable_data_through_pptx() {
if install().is_none() {
eprintln!("SKIP: no pinned LOK build");
return;
}
let (dir, mut worker, doc) = open_deck();
let table = |values: [f64; 2]| ChartData {
categories: vec!["10".into(), "20".into()],
series: vec![("North".into(), values.iter().map(|v| Some(*v)).collect())],
};
let json = show::chart_json(&look("scatter"), Some(&table([1.0, 2.0])));
worker.run(&doc, "show.chart", &[&json]).expect("inserts");
let deck = round_trip(&mut worker, &doc, &dir.path().join("one.pptx"));
let read = chart(&mut worker, &deck);
assert_eq!(read.data, Some(table([1.0, 2.0])), "X and Y come back");
let titled = crate::sheet::Look {
title: "Spread".into(),
..read.look
};
let before = steps(&mut worker, &deck);
let json = show::chart_json(&titled, Some(&table([1.0, 5.0])));
worker.run(&deck, "show.chart", &[&json]).expect("changes");
assert_eq!(steps(&mut worker, &deck), before + 1);
let again = round_trip(&mut worker, &deck, &dir.path().join("two.pptx"));
let read = chart(&mut worker, &again);
assert_eq!(
(read.look.title.as_str(), read.data),
("Spread", Some(table([1.0, 5.0])))
);
}
#[test]
fn a_missing_point_reads_as_none_and_goes_back() {
if install().is_none() {
eprintln!("SKIP: no pinned LOK build");
return;
}
let (_dir, mut worker, doc) = open_deck();
let data = ChartData {
categories: vec!["Q1".into(), "Q2".into()],
series: vec![("North".into(), vec![Some(1.0), None])],
};
let json = show::chart_json(&look("column"), Some(&data));
worker.run(&doc, "show.chart", &[&json]).expect("inserts");
let read = chart(&mut worker, &doc);
assert_eq!(read.data.as_ref(), Some(&data));
let titled = crate::sheet::Look {
title: "Gaps".into(),
..read.look
};
let json = show::chart_json(&titled, read.data.as_ref());
worker.run(&doc, "show.chart", &[&json]).expect("retitles");
assert_eq!(chart(&mut worker, &doc).data, Some(data));
}
#[test]
fn a_chart_field_of_the_wrong_type_is_refused() {
if install().is_none() {
eprintln!("SKIP: no pinned LOK build");
return;
}
let (_dir, mut worker, doc) = open_deck();
let data = r#""data":{"categories":["A"],"series":[{"name":"S","values":[1]}]}"#;
let shapes = |worker: &mut Worker| {
let read = worker.values(&doc, show::OBJECTS).expect("reads");
show::objects(&read).expect("parses").objects.len()
};
let (before, objects) = (steps(&mut worker, &doc), shapes(&mut worker));
for bad in [
format!(r#"{{"type":"column","legend":"banana",{data}}}"#),
format!(r#"{{"type":"column","legend":"true",{data}}}"#),
format!(r#"{{"type":"column","title":123,{data}}}"#),
format!(r#"{{"type":"column","title":{{"bad":"object"}},{data}}}"#),
r#"{"type":"column","data":{"categories":["A"],"series":[{"name":"S","values":["1"]}]}}"#
.into(),
r#"{"type":"scatter","data":{"categories":["A"],"series":[{"name":"S","values":[1]}]}}"#
.into(),
] {
assert!(worker.run(&doc, "show.chart", &[&bad]).is_err(), "{bad}");
}
assert_eq!(steps(&mut worker, &doc), before, "no undo step");
assert_eq!(shapes(&mut worker), objects, "no chart");
}
#[test]
fn comments_do_not_shift_object_indices() {
if install().is_none() {
eprintln!("SKIP: no pinned LOK build");
return;
}
let (_dir, mut worker, doc) = open_deck();
let shape = |worker: &mut Worker, kind: &str| {
worker
.run(&doc, "draw.shape", &[kind, "true"])
.expect("draws");
};
shape(&mut worker, "rectangle");
worker
.run(&doc, "comment.insert", &["Between"])
.expect("comments");
shape(&mut worker, "ellipse");
let read = |worker: &mut Worker| {
show::objects(&worker.values(&doc, show::OBJECTS).expect("reads")).expect("parses")
};
let last = read(&mut worker).objects.len() - 1;
let state = worker.now(&doc).expect("the state");
worker
.run(&state, "object.select", &[&last.to_string(), "false"])
.expect("selects");
let objects = read(&mut worker);
let marked: Vec<bool> = objects.objects.iter().map(|o| o.selected).collect();
assert_eq!(marked.iter().filter(|on| **on).count(), 1, "{marked:?}");
assert!(marked[last], "the ellipse, not the comment");
}
/// Where the first chart series' style sits.
fn series_style(xml: &str) -> (usize, usize) {
let at = xml.find("<chart:series").expect("a series");
let name = xml[at..]
.split("chart:style-name=\"")
.nth(1)
.and_then(|rest| rest.split('"').next())
.expect("a series style");
let start = xml
.find(&format!("<style:style style:name=\"{name}\""))
.expect("its style");
let end = start + xml[start..].find("</style:style>").expect("ends");
(start, end)
}
#[test]
fn a_retitled_scatter_keeps_its_lines() {
if install().is_none() {
eprintln!("SKIP: no pinned LOK build");
return;
}
let (dir, mut worker, doc) = open_deck();
let data = ChartData {
categories: vec!["10".into(), "20".into()],
series: vec![("North".into(), vec![Some(1.0), Some(2.0)])],
};
let json = show::chart_json(&look("scatter"), Some(&data));
worker.run(&doc, "show.chart", &[&json]).expect("inserts");
let out = dir.path().join("points.fodp");
doc.save_as(&mut worker, &crate::file_url(&out), "fodp")
.expect("saves");
// Join the points with a line, as another editor would.
let mut xml = std::fs::read_to_string(&out).expect("reads");
let (start, end) = series_style(&xml);
let style = xml[start..end].replace(r#"draw:stroke="none""#, r#"draw:stroke="solid""#);
assert!(style.contains(r#"draw:stroke="solid""#), "{style}");
xml.replace_range(start..end, &style);
let lines = dir.path().join("lines.fodp");
std::fs::write(&lines, xml).expect("writes");
let deck = worker.load(&crate::file_url(&lines)).expect("loads");
select_chart(&mut worker, &deck);
let titled = crate::sheet::Look {
title: "Spread".into(),
..chart(&mut worker, &deck).look
};
let json = show::chart_json(&titled, None);
worker.run(&deck, "show.chart", &[&json]).expect("retitles");
let again = dir.path().join("again.fodp");
deck.save_as(&mut worker, &crate::file_url(&again), "fodp")
.expect("saves");
let xml = std::fs::read_to_string(&again).expect("reads");
let (start, end) = series_style(&xml);
assert!(
!xml[start..end].contains(r#"draw:stroke="none""#),
"{}",
&xml[start..end]
);
}
+48
View File
@@ -0,0 +1,48 @@
use super::*;
use crate::show;
/// A lost font, a lost linked picture, an empty object.
fn trouble() -> std::path::PathBuf {
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../tests/fixtures/show/trouble.fodp")
}
#[test]
fn the_check_finds_fonts_links_and_broken_objects_by_slide() {
if install().is_none() {
eprintln!("SKIP: no pinned LOK build");
return;
}
let dir = tempfile::tempdir().expect("a temp dir");
let mut worker = Worker::spawn(&install().expect("LOK"), &dir.path().join("p")).expect("up");
// Beside it: an undecodable picture, a linked text.
let path = dir.path().join("trouble.fodp");
std::fs::copy(trouble(), &path).expect("copies");
std::fs::write(dir.path().join("broken.png"), "not a valid image").expect("writes");
let linked = dir.path().join("linked.odt");
let blank = worker.load("private:factory/swriter").expect("a text");
blank.save_as(&mut worker, &crate::file_url(&linked), "odt").expect("saves");
worker.close_doc(blank).expect("closes");
let doc = worker.load(&crate::file_url(&path)).expect("loads");
let json = worker.values(&doc, show::SLIDE_CHECK).expect("reads");
let found = show::check(&json).expect("parses");
assert_eq!(found.slides.len(), 5);
assert!(found.slides[0].fonts.iter().any(|f| f == "Nowhere Sans"), "{json}");
assert!(!found.slides[1].fonts.iter().any(|f| f == "Nowhere Sans"));
assert_eq!(found.slides[1].links, [("file:///nowhere/gone.png".to_string(), true)], "{json}");
assert!(found.slides[1].broken.is_empty(), "said once, as a link");
assert_eq!(found.slides[2].broken, ["Empty"], "{json}");
assert!(!found.masters.is_empty());
let mut bad = found.slides[3].broken.clone();
bad.sort();
assert_eq!(bad, ["Bad", "Junk"], "{json}");
assert_eq!(found.slides[3].links.iter().map(|(_, m)| *m).collect::<Vec<_>>(), [false]);
let ole = |worker: &mut Worker| {
let json = worker.values(&doc, show::SLIDE_CHECK).expect("reads");
show::check(&json).expect("parses").slides[4].links.clone()
};
let there = ole(&mut worker);
assert_eq!(there.len(), 1, "the object is a link");
assert!(there[0].0.ends_with("linked.odt") && !there[0].1);
std::fs::remove_file(&linked).expect("deletes");
assert!(ole(&mut worker)[0].1, "a deleted source is missing");
}
+464
View File
@@ -0,0 +1,464 @@
use super::input::{FODS, open, paragraphs, saved};
use super::*;
use crate::commands;
/// How long a command may take to report.
const SETTLE: std::time::Duration = std::time::Duration::from_secs(10);
/// The latest value the kernel reported for `uno`.
fn state<'a>(seen: &'a Events, uno: &str) -> Option<&'a str> {
seen.states
.iter()
.rev()
.find(|(name, _)| name == uno)
.map(|(_, state)| state.as_str())
}
/// Runs `id` and waits for `uno` to report `want`.
fn run_until(
worker: &mut Worker,
doc: &Doc,
from: &Events,
(id, values): (&str, &[&str]),
(uno, want): (&str, &str),
) -> Events {
worker.run(doc, id, values).expect("the command runs");
let seen = poll_until(worker, doc, from, SETTLE, |seen| {
state(seen, uno) == Some(want)
});
assert_eq!(state(&seen, uno), Some(want), "{id}: {uno}");
seen
}
#[test]
fn every_mapped_command_reaches_the_kernel_and_reports_state() {
if install().is_none() {
eprintln!("SKIP: no pinned LOK build");
return;
}
let (dir, mut worker, doc) = open(FODT);
click(&mut worker, &doc, 150, 150);
let base = poll_until(&mut worker, &doc, &Events::default(), SETTLE, |_| false);
assert_eq!(
state(&base, ".uno:Bold"),
Some("false"),
"the kernel seeds state"
);
let typed = {
worker.post_key(&doc, 'a' as u32, 512).expect("a posts");
poll_until(&mut worker, &doc, &base, SETTLE, |seen| {
state(seen, ".uno:Undo") == Some("enabled")
})
};
assert_eq!(
state(&typed, ".uno:Undo"),
Some("enabled"),
"typing enables undo"
);
assert_eq!(state(&typed, ".uno:ModifiedStatus"), Some("true"));
let seen = run_until(
&mut worker,
&doc,
&typed,
("edit.undo", &[]),
(".uno:Redo", "enabled"),
);
let seen = run_until(
&mut worker,
&doc,
&seen,
("edit.redo", &[]),
(".uno:Undo", "enabled"),
);
worker
.run(&doc, "edit.select_all", &[])
.expect("select all");
let seen = poll_until(&mut worker, &doc, &seen, SETTLE, |seen| {
!seen.selection.is_empty()
});
assert!(!seen.selection.is_empty(), "select all selects");
let mut seen = seen;
for (id, uno) in [
("format.bold", ".uno:Bold"),
("format.italic", ".uno:Italic"),
("format.underline", ".uno:Underline"),
("format.strikeout", ".uno:Strikeout"),
] {
seen = run_until(&mut worker, &doc, &seen, (id, &[]), (uno, "true"));
}
let fonts = worker
.values(&doc, ".uno:CharFontName")
.expect("the font list");
let fonts: serde_json::Value = serde_json::from_str(&fonts).expect("json");
let family = fonts["commandValues"]
.as_object()
.and_then(|fonts| {
fonts
.keys()
.find(|name| name.as_str() != "Liberation Serif")
})
.expect("the kernel lists fonts")
.clone();
seen = run_until(
&mut worker,
&doc,
&seen,
("format.font", &[&family]),
(".uno:CharFontName", &family),
);
seen = run_until(
&mut worker,
&doc,
&seen,
("format.size", &["18"]),
(".uno:FontHeight", "18"),
);
let styles = worker
.values(&doc, ".uno:StyleApply")
.expect("the style list");
assert!(
styles.contains("ParagraphStyles"),
"the kernel lists styles"
);
run_until(
&mut worker,
&doc,
&seen,
("format.style", &["Heading 1", "ParagraphStyles"]),
(".uno:StyleApply", "Heading 1"),
);
let text = saved(&dir, &mut worker, &doc, "out.fodt");
assert!(
paragraphs(&text).is_empty(),
"the paragraph became a heading"
);
let body = text
.replace("<text:h", "<text:p")
.replace("</text:h>", "</text:p>");
assert_eq!(
paragraphs(&body),
["aHello 世界"],
"the heading holds the text"
);
assert!(text.contains("fo:font-weight=\"bold\""), "bold landed");
assert_eq!(
commands::all().count(),
246,
"the rows of text, layout, refs, tools, sheet, calc, data, analysis, page, show and draw"
);
}
#[test]
fn unmapped_commands_and_lists_are_refused() {
if install().is_none() {
eprintln!("SKIP: no pinned LOK build");
return;
}
let (_dir, mut worker, doc) = open(FODT);
assert!(
worker.run(&doc, "format.dialog", &[]).is_err(),
"unknown ids fail here"
);
assert!(
worker.run(&doc, "format.size", &[]).is_err(),
"missing values fail here"
);
let raw = |name: &str, value: &str| Pack::new().u64(doc.id).str(name).u64(1).str(value).bytes();
assert!(
worker
.request(tag::POST_UNO, &raw(".uno:FontDialog", ""))
.is_err(),
"the worker refuses unmapped names"
);
assert!(
worker
.request(tag::POST_UNO, &raw("format.size", "junk"))
.is_err(),
"the worker checks values too"
);
assert!(
worker.values(&doc, ".uno:Copy").is_err(),
"only listed values read"
);
}
#[test]
fn failed_commands_surface_and_change_nothing() {
if install().is_none() {
eprintln!("SKIP: no pinned LOK build");
return;
}
let (dir, mut worker, doc) = open(FODT);
click(&mut worker, &doc, 150, 150);
let base = poll_until(&mut worker, &doc, &Events::default(), SETTLE, |_| false);
let size = state(&base, ".uno:FontHeight")
.expect("a seeded size")
.to_string();
assert!(
worker.run(&doc, "edit.undo", &[]).is_err(),
"nothing to undo is an error"
);
assert!(worker.run(&doc, "format.size", &["junk"]).is_err());
assert!(
worker
.run(&doc, "format.font", &["Ely No Such Font"])
.is_err()
);
let missing = worker.run(
&doc,
"format.style",
&["Ely No Such Style", "ParagraphStyles"],
);
assert!(missing.is_err(), "a missing style is an error: {missing:?}");
let seen = poll_until(&mut worker, &doc, &base, SETTLE, |_| false);
assert_eq!(
state(&seen, ".uno:FontHeight").unwrap_or(&size),
size,
"the size stays"
);
assert_ne!(
state(&seen, ".uno:ModifiedStatus"),
Some("true"),
"nothing changed"
);
let text = saved(&dir, &mut worker, &doc, "out.fodt");
assert_eq!(paragraphs(&text), ["Hello 世界"]);
}
#[test]
fn a_kernel_dialog_is_closed_and_reported() {
let Some(install) = install() else {
eprintln!("SKIP: no pinned LOK build");
return;
};
let (done, wait) = std::sync::mpsc::channel();
let (pid_out, pid_in) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let dir = tempfile::tempdir().expect("a temp dir");
let mut worker = Worker::spawn(&install, &dir.path().join("profile")).expect("the worker");
pid_out.send(worker.child.id()).expect("the test waits");
let source = dir.path().join("in.fods");
write(&source, FODS);
let doc = worker
.load(&format!("file://{}", source.display()))
.expect("the sheet loads");
let base = poll_until(&mut worker, &doc, &Events::default(), SETTLE, |_| false);
// Calc's Backspace opens Delete Contents.
worker.post_key(&doc, 0, 1283).expect("backspace posts");
let seen = poll_until(&mut worker, &doc, &base, SETTLE, |seen| {
!seen.blocked.is_empty()
});
worker
.post_key(&doc, 'z' as u32, 537)
.expect("typing still works");
done.send(seen.blocked).expect("the test waits");
});
let pid = pid_in.recv().expect("the worker starts");
let outcome = wait.recv_timeout(std::time::Duration::from_secs(30));
if outcome.is_err() {
// A blocked worker would hold the test open.
std::process::Command::new("kill")
.args(["-9", &pid.to_string()])
.status()
.expect("kill runs");
}
let blocked = outcome.expect("the dialog never blocked the worker");
assert_eq!(blocked, ["Delete Contents"], "the dialog is reported");
}
#[test]
fn a11y_follows_the_focused_paragraph() {
if install().is_none() {
eprintln!("SKIP: no pinned LOK build");
return;
}
let (_dir, mut worker, doc) = open(FODT);
let opened = worker.poll_events(&doc, 0, 250).expect("the first poll");
assert_eq!(
opened.a11y.text, "Hello 世界",
"the open seeds the paragraph"
);
assert_eq!(opened.a11y.caret, Some(0));
click(&mut worker, &doc, 150, 150);
worker.post_key(&doc, 'a' as u32, 512).expect("a posts");
let seen = poll_until(&mut worker, &doc, &opened, SETTLE, |seen| {
seen.a11y.text == "aHello 世界" && seen.a11y.caret == Some(1)
});
assert_eq!(seen.a11y.text, "aHello 世界", "the paragraph text bridges");
assert_eq!(seen.a11y.caret, Some(1), "the caret bridges");
worker
.run(&doc, "edit.select_all", &[])
.expect("select all");
let all = poll_until(&mut worker, &doc, &seen, SETTLE, |seen| {
seen.a11y.selection == Some((0, 9))
});
assert_eq!(all.a11y.selection, Some((0, 9)), "the selection bridges");
worker
.post_key(&doc, 0, 1027)
.expect("Right drops the selection");
let none = poll_until(&mut worker, &doc, &all, SETTLE, |seen| {
seen.a11y.selection.is_none()
});
assert_eq!(none.a11y.selection, None, "-1 clears it");
}
#[test]
fn a_selected_image_reports_its_frame() {
if install().is_none() {
eprintln!("SKIP: no pinned LOK build");
return;
}
let (_dir, mut worker, doc) = open(FODT);
click(&mut worker, &doc, 150, 150);
let mut png = Vec::new();
image::RgbaImage::from_pixel(200, 200, image::Rgba([255, 0, 0, 255]))
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
.expect("the png encodes");
worker
.paste(&doc, "image/png", &png)
.expect("the image pastes");
let base = poll_until(&mut worker, &doc, &Events::default(), SETTLE, |_| false);
// The image sits as a character at the text start.
click(&mut worker, &doc, 1418 + 1000, 1418 + 1500);
let seen = poll_until(&mut worker, &doc, &base, SETTLE, |seen| {
seen.graphic.is_some()
});
let (x, y, w, h) = seen.graphic.expect("the image frame arrives");
assert!(
(1300..1600).contains(&x) && (1300..1600).contains(&y),
"{:?}",
seen.graphic
);
assert!(w > 0 && h > 0, "the frame has a size");
}
#[test]
fn undo_and_redo_follow_the_live_history() {
if install().is_none() {
eprintln!("SKIP: no pinned LOK build");
return;
}
let (dir, mut worker, doc) = open(FODT);
click(&mut worker, &doc, 150, 150);
worker.post_key(&doc, 'a' as u32, 512).expect("a posts");
worker
.run(&doc, "edit.undo", &[])
.expect("undo right after typing");
assert!(
worker.run(&doc, "edit.undo", &[]).is_err(),
"a second undo has nothing"
);
worker
.run(&doc, "edit.redo", &[])
.expect("redo right after undo");
assert!(
worker.run(&doc, "edit.redo", &[]).is_err(),
"a second redo has nothing"
);
let text = saved(&dir, &mut worker, &doc, "out.fodt");
assert_eq!(paragraphs(&text), ["aHello 世界"]);
}
#[test]
fn a_size_finer_than_the_kernel_keeps_is_refused() {
if install().is_none() {
eprintln!("SKIP: no pinned LOK build");
return;
}
let (dir, mut worker, doc) = open(FODT);
click(&mut worker, &doc, 150, 150);
worker
.run(&doc, "edit.select_all", &[])
.expect("select all");
assert!(worker.run(&doc, "format.size", &["18.01"]).is_err());
let raw = Pack::new()
.u64(doc.id)
.str("format.size")
.u64(1)
.str("18.01")
.bytes();
assert!(
worker.request(tag::POST_UNO, &raw).is_err(),
"the worker refuses it too"
);
worker
.run(&doc, "format.size", &["18.05"])
.expect("0.05 steps run");
let text = saved(&dir, &mut worker, &doc, "out.fodt");
let sizes: Vec<f64> = text
.split("fo:font-size=\"")
.skip(1)
.filter_map(|rest| rest.split("pt").next()?.parse().ok())
.collect();
assert!(
sizes.iter().any(|size| (size - 18.05).abs() < 0.001),
"the kernel keeps 18.05: {sizes:?}"
);
}
#[test]
fn a_refused_size_keeps_the_redo() {
if install().is_none() {
eprintln!("SKIP: no pinned LOK build");
return;
}
let (dir, mut worker, doc) = open(FODT);
click(&mut worker, &doc, 150, 150);
worker.post_key(&doc, 'a' as u32, 512).expect("a posts");
worker.run(&doc, "edit.undo", &[]).expect("undo");
let raw = Pack::new()
.u64(doc.id)
.str("format.size")
.u64(1)
.str("18.05000001")
.bytes();
assert!(
worker.request(tag::POST_UNO, &raw).is_err(),
"the worker refuses it"
);
worker
.run(&doc, "edit.redo", &[])
.expect("the redo survives");
let text = saved(&dir, &mut worker, &doc, "out.fodt");
assert_eq!(paragraphs(&text), ["aHello 世界"]);
}
#[test]
fn an_owned_save_clears_the_modified_state() {
if install().is_none() {
eprintln!("SKIP: no pinned LOK build");
return;
}
let (dir, mut worker, doc) = open(FODT);
click(&mut worker, &doc, 150, 150);
worker.post_key(&doc, 'a' as u32, 512).expect("a posts");
let base = poll_until(&mut worker, &doc, &Events::default(), SETTLE, |seen| {
state(seen, ".uno:ModifiedStatus") == Some("true")
});
let copy = dir.path().join("copy.docx");
doc.save_owned(&mut worker, &format!("file://{}", copy.display()), "docx")
.expect("the owned save");
let saved = poll_until(&mut worker, &doc, &base, SETTLE, |seen| {
state(seen, ".uno:ModifiedStatus") == Some("false")
});
assert_eq!(state(&saved, ".uno:ModifiedStatus"), Some("false"));
assert!(
std::fs::read(&copy).expect("the copy").starts_with(b"PK"),
"a docx zip"
);
worker.post_key(&doc, 'b' as u32, 513).expect("b posts");
let edited = poll_until(&mut worker, &doc, &saved, SETTLE, |seen| {
state(seen, ".uno:ModifiedStatus") == Some("true")
});
assert_eq!(state(&edited, ".uno:ModifiedStatus"), Some("true"));
let bad = Pack::new()
.u64(doc.id)
.str("file:///tmp/x.odt")
.str("odt")
.str("Evil")
.bytes();
assert!(
worker.request(tag::SAVE_AS, &bad).is_err(),
"unknown options are refused"
);
}
+312
View File
@@ -0,0 +1,312 @@
use super::input::{open, saved};
use super::*;
/// How long a command may take to report.
const SETTLE: std::time::Duration = std::time::Duration::from_secs(3);
#[test]
fn text_and_paragraph_formatting_land_in_the_file() {
if install().is_none() {
eprintln!("SKIP: no pinned LOK build");
return;
}
let (dir, mut worker, doc) = open(FODT);
click(&mut worker, &doc, 150, 150);
worker
.run(&doc, "edit.select_all", &[])
.expect("select all");
for (id, values) in [
("format.superscript", vec![]),
("format.color", vec!["16711680"]),
("format.highlight", vec!["65280"]),
("paragraph.center", vec![]),
("paragraph.spacing_15", vec![]),
("paragraph.indent", vec![]),
("list.numbering", vec![]),
] {
worker
.run(&doc, id, &values)
.unwrap_or_else(|error| panic!("{id}: {error}"));
}
let text = saved(&dir, &mut worker, &doc, "out.fodt");
for mark in [
"style:text-position=\"super",
"fo:color=\"#ff0000\"",
"fo:background-color=\"#00ff00\"",
"fo:text-align=\"center\"",
"fo:line-height=\"150%\"",
"<text:list",
] {
assert!(text.contains(mark), "{mark} lands");
}
}
#[test]
fn tab_stops_add_and_remove_through_the_ruler() {
if install().is_none() {
eprintln!("SKIP: no pinned LOK build");
return;
}
let (_dir, mut worker, doc) = open(FODT);
click(&mut worker, &doc, 150, 150);
let base = poll_until(&mut worker, &doc, &Events::default(), SETTLE, |_| false);
let state = worker.now(&doc).expect("the state");
worker
.run(&state, "paragraph.tab", &["-1", "1134", "false"])
.expect("add a 2 cm stop");
let added = poll_until(&mut worker, &doc, &base, SETTLE, |seen| {
seen.tabs.len() == 1
});
assert_eq!(added.tabs.len(), 1, "the ruler shows one stop");
assert!(
(added.tabs[0] - 2000).abs() < 10,
"2 cm in 1/100 mm: {:?}",
added.tabs
);
let state = worker.now(&doc).expect("the state");
worker
.run(&state, "paragraph.tab", &["0", "0", "true"])
.expect("remove it");
let removed = poll_until(&mut worker, &doc, &added, SETTLE, |seen| {
seen.tabs.is_empty()
});
assert!(removed.tabs.is_empty(), "the stop is gone");
}
#[test]
fn styles_are_created_and_updated_from_the_selection() {
if install().is_none() {
eprintln!("SKIP: no pinned LOK build");
return;
}
let (dir, mut worker, doc) = open(FODT);
click(&mut worker, &doc, 150, 150);
worker.run(&doc, "format.bold", &[]).expect("bold");
worker
.run(&doc, "style.new", &["Ely Notes", "2"])
.expect("a paragraph style");
let styles = worker
.values(&doc, ".uno:StyleApply")
.expect("the style list");
assert!(
styles.contains("\"Ely Notes\""),
"the kernel lists the new style"
);
worker.run(&doc, "format.italic", &[]).expect("italic");
worker
.run(&doc, "style.update", &["Ely Notes", "2"])
.expect("the style updates");
worker
.run(&doc, "style.new", &["Ely Mark", "1"])
.expect("a character style");
assert!(
worker.run(&doc, "style.new", &["", "2"]).is_err(),
"a style needs a name"
);
let text = saved(&dir, &mut worker, &doc, "out.fodt");
assert!(
text.contains("style:display-name=\"Ely Notes\"")
|| text.contains("style:name=\"Ely Notes\"")
);
assert!(text.contains("Ely Mark"), "the character style lands");
}
#[test]
fn the_remaining_rows_reach_the_kernel() {
if install().is_none() {
eprintln!("SKIP: no pinned LOK build");
return;
}
let (_dir, mut worker, doc) = open(FODT);
click(&mut worker, &doc, 150, 150);
worker
.run(&doc, "edit.select_all", &[])
.expect("select all");
for id in [
"format.subscript",
"paragraph.right",
"paragraph.justify",
"paragraph.left",
"paragraph.indent",
"paragraph.outdent",
"paragraph.spacing_2",
"paragraph.spacing_1",
"list.bullets",
"list.deeper",
"list.higher",
] {
worker
.run(&doc, id, &[])
.unwrap_or_else(|error| panic!("{id}: {error}"));
}
}
#[test]
fn list_levels_go_deeper_and_back() {
if install().is_none() {
eprintln!("SKIP: no pinned LOK build");
return;
}
let (dir, mut worker, doc) = open(FODT);
click(&mut worker, &doc, 150, 150);
worker.run(&doc, "list.numbering", &[]).expect("numbering");
worker
.run(&doc, "list.deeper", &[])
.expect("one level deeper");
let deeper = saved(&dir, &mut worker, &doc, "deeper.fodt");
assert_eq!(
deeper.matches("<text:list-item").count(),
2,
"level 2 nests"
);
worker.run(&doc, "list.higher", &[]).expect("back up");
let higher = saved(&dir, &mut worker, &doc, "higher.fodt");
assert_eq!(
higher.matches("<text:list-item").count(),
1,
"level 1 again"
);
}
#[test]
fn spacing_above_and_below_set_apart() {
if install().is_none() {
eprintln!("SKIP: no pinned LOK build");
return;
}
let (dir, mut worker, doc) = open(FODT);
click(&mut worker, &doc, 150, 150);
worker
.run(&doc, "paragraph.spacing", &["0", "212"])
.expect("6 pt below");
let below = poll_until(&mut worker, &doc, &Events::default(), SETTLE, |seen| {
state(seen, ".uno:ULSpacing") == Some("0,212")
});
worker
.run(&doc, "paragraph.spacing", &["423", "212"])
.expect("12 pt above");
poll_until(&mut worker, &doc, &below, SETTLE, |seen| {
state(seen, ".uno:ULSpacing") == Some("423,212")
});
let text = saved(&dir, &mut worker, &doc, "spacing.fodt");
assert!(
text.contains("fo:margin-top=\"0.1665in\" fo:margin-bottom=\"0.0835in\""),
"above changed, below kept"
);
}
#[test]
fn the_character_style_is_reported() {
if install().is_none() {
eprintln!("SKIP: no pinned LOK build");
return;
}
let (_dir, mut worker, doc) = open(FODT);
click(&mut worker, &doc, 150, 150);
worker
.run(&doc, "edit.select_all", &[])
.expect("select all");
worker
.run(&doc, "format.style", &["Emphasis", "CharacterStyles"])
.expect("emphasis");
let seen = poll_until(&mut worker, &doc, &Events::default(), SETTLE, |seen| {
state(seen, ".uno:CharStyle") == Some("Emphasis")
});
assert_eq!(state(&seen, ".uno:CharStyle"), Some("Emphasis"));
}
/// The latest value of `uno` in a batch.
fn state<'a>(seen: &'a Events, uno: &str) -> Option<&'a str> {
seen.states
.iter()
.rev()
.find(|(name, _)| name == uno)
.map(|(_, value)| value.as_str())
}
#[test]
fn mixed_selections_report_dont_care() {
if install().is_none() {
eprintln!("SKIP: no pinned LOK build");
return;
}
let (_dir, mut worker, doc) = open(FODT);
click(&mut worker, &doc, 150, 150);
for key in "One".chars() {
worker.post_key(&doc, key as u32, 0).expect("a key");
}
worker
.run(&doc, "format.style", &["Heading 1", "ParagraphStyles"])
.expect("a heading");
worker.post_key(&doc, 0, 1029).expect("end");
worker.post_key(&doc, 13, 1280).expect("enter");
for key in "Two".chars() {
worker.post_key(&doc, key as u32, 0).expect("a key");
}
worker
.post_key(&doc, 0, 1028 | 0x1000)
.expect("select the word");
worker
.run(&doc, "format.style", &["Emphasis", "CharacterStyles"])
.expect("emphasis");
worker
.run(&doc, "edit.select_all", &[])
.expect("select all");
let names = [
".uno:Italic",
".uno:CharStyle",
".uno:StyleApply",
".uno:ULSpacing",
];
let seen = poll_until(&mut worker, &doc, &Events::default(), SETTLE, |seen| {
names
.iter()
.all(|name| state(seen, name) == Some(crate::MIXED))
});
for name in names {
assert_eq!(state(&seen, name), Some(crate::MIXED), "{name} is mixed");
}
}
#[test]
fn character_styles_mix_their_attributes() {
if install().is_none() {
eprintln!("SKIP: no pinned LOK build");
return;
}
let (_dir, mut worker, doc) = open(FODT);
click(&mut worker, &doc, 150, 150);
for key in "Alpha Beta".chars() {
worker.post_key(&doc, key as u32, 0).expect("a key");
}
worker.post_key(&doc, 0, 1028).expect("home");
for _ in 0..5 {
worker.post_key(&doc, 0, 1027 | 0x1000).expect("select");
}
worker
.run(&doc, "format.style", &["Source Text", "CharacterStyles"])
.expect("a monospace style");
worker.post_key(&doc, 0, 1029).expect("end");
for _ in 0..4 {
worker.post_key(&doc, 0, 1026 | 0x1000).expect("select");
}
worker
.run(
&doc,
"format.style",
&["Strong Emphasis", "CharacterStyles"],
)
.expect("a bold style");
worker
.run(&doc, "edit.select_all", &[])
.expect("select all");
let names = [".uno:CharFontName", ".uno:Bold", ".uno:CharStyle"];
let seen = poll_until(&mut worker, &doc, &Events::default(), SETTLE, |seen| {
names
.iter()
.all(|name| state(seen, name) == Some(crate::MIXED))
});
for name in names {
assert_eq!(state(&seen, name), Some(crate::MIXED), "{name} is mixed");
}
}
+362
View File
@@ -0,0 +1,362 @@
use super::input::FODS;
use super::*;
/// How long the kernel may take to answer.
const SETTLE: std::time::Duration = std::time::Duration::from_secs(3);
/// The FODS fixture, loaded and its cursor read.
fn open() -> (tempfile::TempDir, Worker, Doc, Events) {
let install = install().expect("the pinned LOK build");
let dir = tempfile::tempdir().expect("a temp dir");
let mut worker = Worker::spawn(&install, &dir.path().join("profile")).expect("the worker");
let source = dir.path().join("in.fods");
write(&source, FODS);
let doc = worker
.load(&format!("file://{}", source.display()))
.expect("the sheet loads");
let seen = poll_until(&mut worker, &doc, &Events::default(), SETTLE, |seen| {
seen.cell.cursor.is_some()
});
(dir, worker, doc, seen)
}
fn type_text(worker: &mut Worker, doc: &Doc, text: &str) {
for c in text.chars() {
worker.post_key(doc, c as u32, 0).expect("a key posts");
}
}
/// The cells of `range`, as the kernel copies them.
fn values(worker: &mut Worker, doc: &Doc, range: &str) -> String {
worker.run(doc, "cell.goto", &[range]).expect("the range");
doc.selection_text(worker).expect("the text")
}
#[test]
fn typing_a_formula_offers_functions_usage_and_marks() {
if install().is_none() {
eprintln!("SKIP: no pinned LOK build");
return;
}
let (_dir, mut worker, doc, seen) = open();
worker.post_key(&doc, 0, 1024).expect("Down to A2");
type_text(&mut worker, &doc, "=SU");
let offered = poll_until(&mut worker, &doc, &seen, SETTLE, |seen| {
seen.cell.functions.iter().any(|f| f.name == "SUMIF")
});
let sum = offered.cell.functions.iter().find(|f| f.name == "SUM");
assert!(
sum.is_some_and(|f| f.signature.contains(", ")),
"the signature takes the Excel separator: {sum:?}"
);
doc.complete_function(&mut worker, "SUMIF")
.expect("the completion");
let done = poll_until(&mut worker, &doc, &offered, SETTLE, |seen| {
seen.cell.formula == "=SUMIF()"
});
assert_eq!(done.cell.formula, "=SUMIF()", "the name completes");
type_text(&mut worker, &doc, "A1,");
let usage = poll_until(&mut worker, &doc, &done, SETTLE, |seen| {
seen.cell.formula == "=SUMIF(A1,)" && seen.cell.usage.contains('►')
});
assert_eq!(usage.cell.formula, "=SUMIF(A1,)", "typing goes on inside");
assert!(
usage.cell.usage.contains("► Criteria"),
"{}",
usage.cell.usage
);
assert!(usage.cell.functions.is_empty(), "the list gives way");
let b3 = usage.cell.cursor.expect("the cell cursor");
let (x, y) = (b3.0 + b3.2 * 3 / 2, b3.1 + b3.3 * 3 / 2);
for kind in [0, 1] {
let click = Mouse {
kind,
x,
y,
count: 1,
buttons: 1,
modifiers: 0,
};
worker.post_mouse(&doc, click).expect("the click posts");
}
let picked = poll_until(&mut worker, &doc, &usage, SETTLE, |seen| {
seen.cell.formula == "=SUMIF(A1,B3)" && !seen.cell.marks.is_empty()
});
assert_eq!(
picked.cell.formula, "=SUMIF(A1,B3)",
"a click picks the cell"
);
assert_eq!(picked.cell.marks.len(), 1, "the picked cell is marked");
assert_eq!(picked.cell.marks[0].part, 0);
type_text(&mut worker, &doc, "*2");
let typed = poll_until(&mut worker, &doc, &picked, SETTLE, |seen| {
seen.cell.formula == "=SUMIF(A1,B3*2)"
});
assert_eq!(
typed.cell.formula, "=SUMIF(A1,B3*2)",
"typing goes on after a pick"
);
let picked = typed;
// The first Escape may only close the kernel's hint.
worker.post_key(&doc, 0, 1281).expect("Escape");
worker.post_key(&doc, 0, 1281).expect("Escape cancels");
let done = poll_until(&mut worker, &doc, &picked, SETTLE, |seen| {
seen.cell.marks.is_empty() && seen.cell.formula.is_empty()
});
assert!(done.cell.marks.is_empty(), "{:?}", done.cell);
worker
.run(&doc, "cell.enter", &["=A1+B3"])
.expect("a formula");
worker.run(&doc, "cell.goto", &["A2"]).expect("back to it");
worker.post_key(&doc, 0, 769).expect("F2 edits it");
let edited = poll_until(&mut worker, &doc, &done, SETTLE, |seen| {
seen.cell.marks.len() == 2
});
assert_eq!(edited.cell.marks.len(), 2, "each reference is marked");
worker.post_key(&doc, 0, 1280).expect("Enter commits");
let entered = poll_until(&mut worker, &doc, &edited, SETTLE, |seen| {
seen.cell.marks.is_empty()
});
assert!(entered.cell.marks.is_empty(), "the commit clears the marks");
}
#[test]
fn a_broken_formula_enters_as_typed_without_a_dialog() {
if install().is_none() {
eprintln!("SKIP: no pinned LOK build");
return;
}
let (_dir, mut worker, doc, seen) = open();
worker.run(&doc, "cell.goto", &["C1"]).expect("C1");
type_text(&mut worker, &doc, "=1+");
worker.post_key(&doc, 0, 1280).expect("Enter");
let entered = poll_until(&mut worker, &doc, &seen, SETTLE, |seen| {
seen.cell.address == "C2"
});
assert!(
entered.blocked.is_empty(),
"no dialog: {:?}",
entered.blocked
);
assert!(values(&mut worker, &doc, "C1").starts_with("Err:"));
}
#[test]
fn formulas_take_excel_syntax() {
if install().is_none() {
eprintln!("SKIP: no pinned LOK build");
return;
}
let (_dir, mut worker, doc, _) = open();
let sheet = doc.sheets(&mut worker).expect("the sheets").list[0]
.name
.clone();
for (cell, formula) in [
("D1", "=SUM(1,2)"),
("D2", &format!("='{sheet}'!D1*2")),
("D3", "=SUM($D$1:D2,1)"),
] {
worker.run(&doc, "cell.goto", &[cell]).expect("the cell");
worker
.run(&doc, "cell.enter", &[formula])
.expect("the entry");
}
assert_eq!(values(&mut worker, &doc, "D1:D3"), "3\n6\n10\n");
}
/// Enters each formula at its cell.
fn enter(worker: &mut Worker, doc: &Doc, cells: &[(&str, &str)]) {
for (cell, formula) in cells {
worker.run(doc, "cell.goto", &[cell]).expect("the cell");
worker
.run(doc, "cell.enter", &[formula])
.expect("the entry");
}
}
#[test]
fn the_kernel_computes_each_family_of_functions() {
if install().is_none() {
eprintln!("SKIP: no pinned LOK build");
return;
}
let (_dir, mut worker, doc, _) = open();
enter(
&mut worker,
&doc,
&[
("F1", "3"),
("F2", "1"),
("F3", "2"),
("F4", "3"),
("G1", "=F1^2+F2*4-F3/2"),
("G2", "=IF(AND(F1>2,NOT(F2=2)),\"yes\",\"no\")"),
("G3", "=COUNTIF(F1:F4,3)+SUMIFS(F1:F4,F1:F4,\">1\")"),
(
"G4",
"=CONCAT(LEFT(\"Ely Office\",3),\"-\",TEXT(0.25,\"0%\"))",
),
(
"G5",
"=DATEDIF(DATE(2026,1,31),EDATE(DATE(2026,1,31),1),\"d\")",
),
(
"G6",
"=XLOOKUP(2,F2:F4,F2:F4*10)+INDEX(F1:F4,MATCH(1,F1:F4,0))",
),
("G7", "=SUM(SORT(UNIQUE(F1:F4),1,-1))"),
("G8", "=ROWS(FILTER(F1:F4,F1:F4>1))*SUM(SEQUENCE(3))"),
],
);
assert_eq!(
values(&mut worker, &doc, "G1:G8"),
"12\nyes\n10\nEly-25%\n28\n21\n6\n18\n"
);
}
#[test]
fn manual_calculation_waits_and_survives_a_save() {
if install().is_none() {
eprintln!("SKIP: no pinned LOK build");
return;
}
let (dir, mut worker, doc, seen) = open();
enter(&mut worker, &doc, &[("F1", "1"), ("F2", "=F1*2")]);
assert_eq!(values(&mut worker, &doc, "F2"), "2");
worker.run(&doc, "calc.auto", &["false"]).expect("manual");
let manual = poll_until(&mut worker, &doc, &seen, SETTLE, |seen| {
seen.states
.contains(&(".uno:AutomaticCalculation".into(), "false".into()))
});
assert!(
manual
.states
.contains(&(".uno:AutomaticCalculation".into(), "false".into())),
"the state follows"
);
enter(&mut worker, &doc, &[("F1", "5")]);
assert_eq!(values(&mut worker, &doc, "F2"), "2", "the formula waits");
worker.run(&doc, "calc.now", &[]).expect("a recalc");
assert_eq!(values(&mut worker, &doc, "F2"), "10", "a recalc updates");
for format in ["ods", "xlsx", "xls", "fods"] {
let path = dir.path().join(format!("manual.{format}"));
doc.save_as(&mut worker, &crate::file_url(&path), format)
.expect("the save");
let again = worker
.load(&crate::file_url(&path))
.expect("the copy loads");
let state = poll_until(&mut worker, &again, &Events::default(), SETTLE, |seen| {
seen.states
.iter()
.any(|(name, _)| name == ".uno:AutomaticCalculation")
});
let auto = state
.states
.iter()
.rfind(|(name, _)| name == ".uno:AutomaticCalculation")
.map(|(_, value)| value.as_str());
assert_eq!(auto, Some("false"), "{format} keeps manual calculation");
worker.close_doc(again).expect("the copy closes");
}
}
#[test]
fn a_circular_reference_reports_its_error() {
if install().is_none() {
eprintln!("SKIP: no pinned LOK build");
return;
}
let (_dir, mut worker, doc, seen) = open();
enter(&mut worker, &doc, &[("F1", "=F2+1"), ("F2", "=F1")]);
worker.run(&doc, "cell.goto", &["F1"]).expect("F1");
let state = poll_until(&mut worker, &doc, &seen, SETTLE, |seen| {
seen.states
.iter()
.any(|(name, value)| name == ".uno:StateTableCell" && value.contains("Circular"))
});
assert!(
state
.states
.iter()
.any(|(_, value)| value == "Error: Circular reference"),
"{:?}",
state.states
);
assert_eq!(values(&mut worker, &doc, "F1"), "Err:522");
}
#[test]
fn names_are_defined_changed_removed_and_flag_breaks() {
if install().is_none() {
eprintln!("SKIP: no pinned LOK build");
return;
}
let (_dir, mut worker, doc, _) = open();
let sheet = doc.sheets(&mut worker).expect("the sheets").list[0]
.name
.clone();
let read = |worker: &mut Worker| {
crate::sheet::names(&worker.values(&doc, crate::sheet::NAMES).expect("names"))
.expect("the names parse")
};
enter(&mut worker, &doc, &[("F1", "2"), ("F2", "5")]);
let range = format!("'{sheet}'!$F$1");
worker
.run(&doc, "names.define", &["Rate", &range, ""])
.expect("a workbook name");
worker
.run(&doc, "names.define", &["Local", "$F$2", &sheet])
.expect("a sheet name");
enter(&mut worker, &doc, &[("G1", "=Rate*10+Local")]);
assert_eq!(values(&mut worker, &doc, "G1"), "25");
let names = read(&mut worker);
let rate = names.iter().find(|name| name.name == "Rate").expect("Rate");
assert_eq!((rate.scope.as_str(), rate.broken), ("", false));
assert!(
names
.iter()
.any(|name| name.name == "Local" && name.scope == sheet)
);
let moved = format!("'{sheet}'!$F$2");
worker
.run(&doc, "names.define", &["Rate", &moved, ""])
.expect("a new range");
assert_eq!(values(&mut worker, &doc, "G1"), "55", "the range moves");
worker.run(&doc, "edit.undo", &[]).expect("an undo");
assert_eq!(values(&mut worker, &doc, "G1"), "25", "the change undoes");
assert!(
worker
.run(&doc, "names.define", &["A1", "$F$1", ""])
.is_err(),
"a cell address is no name"
);
let check = |worker: &mut Worker, name: &str| {
let query = crate::sheet::name_check_query(name);
crate::sheet::name_check(&worker.values(&doc, &query).expect("a check"))
.expect("the check parses")
};
assert_eq!(check(&mut worker, "Tax_2026"), "valid");
assert_eq!(check(&mut worker, "B2"), "address");
assert_eq!(check(&mut worker, "a b&c"), "characters");
worker.run(&doc, "cell.goto", &["Rate"]).expect("a jump");
assert_eq!(
poll_until(&mut worker, &doc, &Events::default(), SETTLE, |seen| {
seen.cell.address == "Rate"
})
.cell
.address,
"Rate",
"the name box jumps to a name and shows it"
);
worker.run(&doc, "cell.goto", &["F1"]).expect("F1");
worker.run(&doc, "rows.delete", &[]).expect("row 1 goes");
let names = read(&mut worker);
assert!(
names.iter().any(|name| name.name == "Rate" && name.broken),
"a deleted target breaks the name: {names:?}"
);
worker
.run(&doc, "names.remove", &["Local", &sheet])
.expect("a removal");
assert!(!read(&mut worker).iter().any(|name| name.name == "Local"));
}
@@ -0,0 +1,68 @@
use super::output::pdf_text;
use super::*;
/// Five slides, the fourth hidden.
const FIVE: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
<office:document xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0"
xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0"
xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0"
xmlns:draw="urn:oasis:names:tc:opendocument:xmlns:drawing:1.0"
xmlns:presentation="urn:oasis:names:tc:opendocument:xmlns:presentation:1.0"
xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0"
office:version="1.3" office:mimetype="application/vnd.oasis.opendocument.presentation">
<office:automatic-styles>
<style:style style:name="dp2" style:family="drawing-page">
<style:drawing-page-properties presentation:visibility="hidden"/>
</style:style>
</office:automatic-styles>
<office:body><office:presentation>
<draw:page draw:name="A"><draw:frame svg:x="2cm" svg:y="2cm" svg:width="10cm" svg:height="3cm"><draw:text-box><text:p>Alpha</text:p></draw:text-box></draw:frame></draw:page>
<draw:page draw:name="B"><draw:frame svg:x="2cm" svg:y="2cm" svg:width="10cm" svg:height="3cm"><draw:text-box><text:p>Bravo</text:p></draw:text-box></draw:frame></draw:page>
<draw:page draw:name="C"><draw:frame svg:x="2cm" svg:y="2cm" svg:width="10cm" svg:height="3cm"><draw:text-box><text:p>Charlie</text:p></draw:text-box></draw:frame></draw:page>
<draw:page draw:name="D" draw:style-name="dp2"><draw:frame svg:x="2cm" svg:y="2cm" svg:width="10cm" svg:height="3cm"><draw:text-box><text:p>Delta</text:p></draw:text-box></draw:frame></draw:page>
<draw:page draw:name="E"><draw:frame svg:x="2cm" svg:y="2cm" svg:width="10cm" svg:height="3cm"><draw:text-box><text:p>Echo</text:p></draw:text-box></draw:frame></draw:page>
</office:presentation></office:body>
</office:document>"#;
fn handouts(pages: Option<(u32, u32)>, per: u8) -> crate::Pdf {
crate::Pdf {
pages,
handouts: per,
..crate::Pdf::default()
}
}
#[test]
fn handouts_lay_shown_slides_in_range_per_page() {
if install().is_none() {
eprintln!("SKIP: no pinned LOK build");
return;
}
let dir = tempfile::tempdir().expect("a temp dir");
let mut worker = Worker::spawn(&install().expect("LOK"), &dir.path().join("p")).expect("up");
let path = dir.path().join("five.fodp");
std::fs::write(&path, FIVE).expect("writes");
let doc = worker.load(&crate::file_url(&path)).expect("loads");
let pdf = dir.path().join("two.pdf");
let url = crate::file_url(&pdf);
doc.export_pdf(&mut worker, &url, &handouts(None, 2)).expect("exports");
let some = dir.path().join("some.pdf");
doc.export_pdf(&mut worker, &crate::file_url(&some), &handouts(Some((2, 3)), 4))
.expect("exports a range");
assert!(
doc.export_pdf(&mut worker, &url, &handouts(None, 5)).is_err(),
"five a page is not a handout layout"
);
let (Some(all), Some(part)) = (pdf_text(&pdf), pdf_text(&some)) else {
eprintln!("SKIP: no pdftotext to read the PDF");
return;
};
let pages: Vec<&str> = all.split('\x0c').filter(|page| !page.trim().is_empty()).collect();
assert_eq!(pages.len(), 2, "four shown slides, two a page: {all:?}");
assert!(pages[0].contains("Alpha") && pages[0].contains("Bravo"));
assert!(pages[1].contains("Charlie") && pages[1].contains("Echo"));
assert!(!all.contains("Delta"), "hidden slides stay out");
let pages: Vec<&str> = part.split('\x0c').filter(|page| !page.trim().is_empty()).collect();
assert_eq!(pages.len(), 1);
assert!(part.contains("Bravo") && part.contains("Charlie") && !part.contains("Alpha"));
}
@@ -0,0 +1,100 @@
//! §19.2: Ely edits, saves, reopens; another reader checks.
use std::{path::Path, process::Command};
use super::input::{FODS, open};
use super::roundtrip::type_in;
use super::show::open_deck;
use super::*;
/// Saves as `format`, closes, reopens for a second edit.
fn reopened(worker: &mut Worker, doc: Doc, dir: &Path, name: &str, format: &str) -> Doc {
let first = dir.join(format!("{name}.{format}"));
doc.save_as(worker, &crate::file_url(&first), format).expect("the first save");
worker.close_doc(doc).expect("closes");
worker.load(&crate::file_url(&first)).expect("reopens")
}
/// The second save, read by `tests/scripts/ooxml.py`.
fn checked(worker: &mut Worker, doc: Doc, dir: &Path, case: &str, format: &str) {
let last = dir.join(format!("{case}-again.{format}"));
doc.save_as(worker, &crate::file_url(&last), format).expect("the second save");
worker.close_doc(doc).expect("closes");
let reader = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../tests/scripts/ooxml.py");
// The file passes, and each known damage to it fails.
for args in [vec![case], vec!["selftest", case]] {
let out = match Command::new("python3").arg(&reader).args(&args).arg(&last).output() {
Ok(out) => out,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return eprintln!("SKIP: no python3 to read {case}");
}
Err(error) => panic!("the reader did not run: {error}"),
};
let said = format!("{}{}", String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr));
assert!(out.status.success(), "{args:?}: {said}");
}
}
#[test]
fn a_docx_keeps_text_comments_and_tracking_across_sessions() {
if install().is_none() {
eprintln!("SKIP: no pinned LOK build");
return;
}
let (dir, mut worker, doc) = open(FODT);
click(&mut worker, &doc, 150, 150);
type_in(&mut worker, &doc, "Alpha 甲 ");
worker.run(&doc, "comment.insert", &["Check alpha"]).expect("comments");
worker.run(&doc, "track.toggle", &[]).expect("tracks");
type_in(&mut worker, &doc, "Beta ");
let doc = reopened(&mut worker, doc, dir.path(), "contract", "docx");
type_in(&mut worker, &doc, "Gamma ");
checked(&mut worker, doc, dir.path(), "contract", "docx");
}
#[test]
fn an_xlsx_keeps_formulas_and_results_across_sessions() {
if install().is_none() {
eprintln!("SKIP: no pinned LOK build");
return;
}
let (dir, mut worker, doc) = open(FODS);
let enter = |worker: &mut Worker, doc: &Doc, cells: &[(&str, &str)]| {
for (cell, entry) in cells {
worker.run(doc, "cell.goto", &[cell]).expect("the cell");
worker.run(doc, "cell.enter", &[entry]).expect("the entry");
}
};
enter(&mut worker, &doc, &[
("H1", "7"),
("H2", "5"),
("H3", "=H1*H2"),
("H4", "=H1/3"),
("H5", "=DATE(2024,2,29)"),
("H6", "=1/0"),
("H7", "=\"Ely\"&\"-\"&TEXT(H1,\"0\")"),
("H8", "=SUM(H1:H2)"),
]);
let doc = reopened(&mut worker, doc, dir.path(), "sheet", "xlsx");
poll_until(&mut worker, &doc, &Events::default(), std::time::Duration::from_secs(3), |seen| {
seen.cell.cursor.is_some()
});
enter(&mut worker, &doc, &[("H9", "=H3+1")]);
checked(&mut worker, doc, dir.path(), "sheet", "xlsx");
}
#[test]
fn a_pptx_keeps_slides_hiding_and_notes_across_sessions() {
if install().is_none() {
eprintln!("SKIP: no pinned LOK build");
return;
}
let (dir, mut worker, doc) = open_deck();
let at = worker.now(&doc).expect("the state");
worker.run(&at, "notes.set", &["Speaker 甲", "0"]).expect("notes");
doc.show_sheet(&mut worker, 1).expect("slide two");
worker.run(&doc, "slide.hide", &[]).expect("hides");
let doc = reopened(&mut worker, doc, dir.path(), "deck", "pptx");
worker.run(&doc, "slide.insert", &[]).expect("a third slide");
checked(&mut worker, doc, dir.path(), "deck", "pptx");
}
+475
View File
@@ -0,0 +1,475 @@
use super::*;
/// How long an input may take to show.
const SETTLE: std::time::Duration = std::time::Duration::from_secs(3);
/// vcl KEY_A, KEY_MOD1.
const KEY_A: i32 = 512;
const MOD1: i32 = 0x2000;
/// A flat document holding a 2x1 table.
const TABLE_FODT: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
<office:document xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0"
xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0"
xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0" office:version="1.3" office:mimetype="application/vnd.oasis.opendocument.text">
<office:body><office:text><table:table table:name="T"><table:table-column table:number-columns-repeated="2"/>
<table:table-row><table:table-cell><text:p>left</text:p></table:table-cell><table:table-cell><text:p>right</text:p></table:table-cell></table:table-row>
</table:table><text:p>after</text:p></office:text></office:body>
</office:document>"#;
/// A worker with one loaded document.
pub(super) fn open(body: &str) -> (tempfile::TempDir, Worker, Doc) {
let install = install().expect("the pinned LOK build");
let dir = tempfile::tempdir().expect("a temp dir");
let mut worker = Worker::spawn(&install, &dir.path().join("profile")).expect("the worker");
let source = dir.path().join("in.fodt");
write(&source, body);
let doc = worker
.load(&format!("file://{}", source.display()))
.expect("the document loads");
(dir, worker, doc)
}
/// The document saved as flat XML.
pub(super) fn saved(dir: &tempfile::TempDir, worker: &mut Worker, doc: &Doc, name: &str) -> String {
let out = dir.path().join(name);
doc.save_as(worker, &format!("file://{}", out.display()), "fodt")
.expect("the save works");
std::fs::read_to_string(&out).expect("the save reads")
}
/// Each paragraph's text, tags stripped.
pub(crate) fn paragraphs(xml: &str) -> Vec<String> {
xml.split("<text:p")
.skip(1)
.map(|rest| {
let body = &rest[rest.find('>').map_or(0, |at| at + 1)..];
let body = &body[..body.find("</text:p>").unwrap_or(0)];
let mut text = String::new();
let mut tag = false;
for c in body.chars() {
match c {
'<' => tag = true,
'>' => tag = false,
c if !tag => text.push(c),
_ => {}
}
}
text
})
.collect()
}
/// A 4x4 red PNG.
pub(super) fn red_png() -> Vec<u8> {
let image = image::RgbaImage::from_pixel(4, 4, image::Rgba([255, 0, 0, 255]));
let mut png = Vec::new();
image
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
.expect("the png encodes");
png
}
#[test]
fn an_ime_composition_commits_once() {
if install().is_none() {
eprintln!("SKIP: no pinned LOK build");
return;
}
let (dir, mut worker, doc) = open(FODT);
click(&mut worker, &doc, 150, 150);
for step in ["n", "ni", "你"] {
worker
.post_text(&doc, false, step)
.expect("the update posts");
}
worker.post_text(&doc, true, "").expect("the commit posts");
let text = paragraphs(&saved(&dir, &mut worker, &doc, "out.fodt"));
assert_eq!(text, ["你Hello 世界"], "the composition lands once");
}
#[test]
fn a_cancelled_composition_leaves_nothing() {
if install().is_none() {
eprintln!("SKIP: no pinned LOK build");
return;
}
let (dir, mut worker, doc) = open(FODT);
click(&mut worker, &doc, 150, 150);
worker
.post_text(&doc, false, "ni")
.expect("the update posts");
worker.post_text(&doc, false, "").expect("the cancel posts");
worker.post_text(&doc, true, "").expect("the end posts");
let text = paragraphs(&saved(&dir, &mut worker, &doc, "out.fodt"));
assert_eq!(text, ["Hello 世界"], "the paragraph is untouched");
}
#[test]
fn keys_carry_chars_and_modifiers() {
if install().is_none() {
eprintln!("SKIP: no pinned LOK build");
return;
}
let (dir, mut worker, doc) = open(FODT);
click(&mut worker, &doc, 150, 150);
worker.post_key(&doc, 'a' as u32, KEY_A).expect("a posts");
let text = paragraphs(&saved(&dir, &mut worker, &doc, "out.fodt"));
assert_eq!(text, ["aHello 世界"], "the char lands");
let base = poll_until(&mut worker, &doc, &Events::default(), SETTLE, |_| false);
worker
.post_key(&doc, 0, MOD1 | KEY_A)
.expect("select all posts");
let seen = poll_until(&mut worker, &doc, &base, SETTLE, |seen| {
!seen.selection.is_empty()
});
assert!(!seen.selection.is_empty(), "mod1+A selects: {seen:?}");
}
#[test]
fn the_kernels_close_keys_are_refused() {
if install().is_none() {
eprintln!("SKIP: no pinned LOK build");
return;
}
let (dir, mut worker, doc) = open(FODT);
// Mod1+W, Mod1+F4, Mod1+Q close the frame or quit.
for key in [534, 771, 528] {
let refused = worker.post_key(&doc, 0, MOD1 | key).expect_err("refused");
assert!(refused.to_string().contains("closes the kernel's frame"), "{refused}");
}
std::thread::sleep(std::time::Duration::from_millis(500));
let text = paragraphs(&saved(&dir, &mut worker, &doc, "out.fodt"));
assert_eq!(text, ["Hello 世界"], "the document stays open");
}
#[test]
fn the_cursor_and_selection_arrive_in_twips() {
if install().is_none() {
eprintln!("SKIP: no pinned LOK build");
return;
}
let (_dir, mut worker, doc) = open(FODT);
let (width, height) = doc.size(&mut worker).expect("the size");
click(&mut worker, &doc, 150, 150);
let seen = poll_until(&mut worker, &doc, &Events::default(), SETTLE, |seen| {
seen.cursor.is_some()
});
let (x, y, w, h) = seen.cursor.expect("a cursor rect");
assert!(
x >= 0 && y >= 0 && x < width as i32 && y < height as i32 && w >= 0 && h > 0,
"the cursor sits in the page: {:?}",
seen.cursor
);
assert!(seen.cursor_visible, "the cursor shows");
let down = |kind, x| Mouse {
kind,
x,
y: y + h / 2,
count: 1,
buttons: 1,
modifiers: 0,
};
worker.post_mouse(&doc, down(0, x)).expect("press");
worker.post_mouse(&doc, down(2, x + 1500)).expect("drag");
worker.post_mouse(&doc, down(1, x + 1500)).expect("release");
let dragged = poll_until(&mut worker, &doc, &seen, SETTLE, |seen| {
!seen.selection.is_empty()
});
assert!(!dragged.selection.is_empty(), "a drag selects: {dragged:?}");
}
#[test]
fn copy_gives_text_and_html_and_paste_takes_html() {
if install().is_none() {
eprintln!("SKIP: no pinned LOK build");
return;
}
let (dir, mut worker, doc) = open(FODT);
click(&mut worker, &doc, 150, 150);
assert!(
worker.copy(&doc).expect("copy runs").is_empty(),
"nothing selected, nothing copied"
);
worker
.run(&doc, "edit.select_all", &[])
.expect("select all");
let flavors = worker.copy(&doc).expect("copy runs");
let get = |mime: &str| {
flavors
.iter()
.find(|(name, _)| name == mime)
.map(|(_, data)| String::from_utf8_lossy(data).into_owned())
};
assert_eq!(
get("text/plain;charset=utf-8").as_deref(),
Some("Hello 世界")
);
let html = get("text/html").expect("an html flavor");
assert!(html.contains("Hello"), "the html holds the text: {html}");
assert!(!html.contains('\0'), "no NUL rides along");
let target = dir.path().join("b.fodt");
write(&target, FODT);
let other = worker
.load(&format!("file://{}", target.display()))
.expect("b loads");
click(&mut worker, &other, 150, 150);
worker
.paste(&other, "text/html", html.as_bytes())
.expect("the html pastes");
let text = saved(&dir, &mut worker, &other, "b-out.fodt");
assert_eq!(
text.matches("Hello").count(),
2,
"b gained the text: {text}"
);
}
#[test]
fn a_cut_after_copy_removes_the_selection() {
if install().is_none() {
eprintln!("SKIP: no pinned LOK build");
return;
}
let (dir, mut worker, doc) = open(FODT);
click(&mut worker, &doc, 150, 150);
worker
.run(&doc, "edit.select_all", &[])
.expect("select all");
assert!(
!worker.copy(&doc).expect("copy runs").is_empty(),
"the copy has flavors"
);
worker.cut(&doc).expect("the cut runs");
let text = paragraphs(&saved(&dir, &mut worker, &doc, "out.fodt"));
assert_eq!(text, [""], "the selection is gone");
}
#[test]
fn a_paste_the_engine_drops_is_an_error() {
if install().is_none() {
eprintln!("SKIP: no pinned LOK build");
return;
}
let (dir, mut worker, doc) = open(FODT);
click(&mut worker, &doc, 150, 150);
let broken = worker.paste(&doc, "image/png", b"this is not a png");
assert!(broken.is_err(), "a corrupt image is refused: {broken:?}");
assert!(
worker.paste(&doc, "text/html", b"").is_err(),
"an empty paste is refused"
);
let text = saved(&dir, &mut worker, &doc, "out.fodt");
assert_eq!(paragraphs(&text), ["Hello 世界"]);
assert!(!text.contains("<draw:image"));
}
#[test]
fn a_corrupt_image_over_a_selection_keeps_the_text() {
if install().is_none() {
eprintln!("SKIP: no pinned LOK build");
return;
}
let (dir, mut worker, doc) = open(FODT);
click(&mut worker, &doc, 150, 150);
worker
.run(&doc, "edit.select_all", &[])
.expect("select all");
let mut bad = red_png();
bad.truncate(bad.len() / 2);
assert!(
worker.paste(&doc, "image/png", &bad).is_err(),
"a cut-off png is refused"
);
assert!(
!worker.copy(&doc).expect("copy runs").is_empty(),
"the selection stays"
);
let text = saved(&dir, &mut worker, &doc, "out.fodt");
assert_eq!(paragraphs(&text), ["Hello 世界"], "the text stays");
}
/// A spreadsheet whose A1 holds text.
pub(crate) const FODS: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
<office:document xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0"
xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0"
xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0" office:version="1.3" office:mimetype="application/vnd.oasis.opendocument.spreadsheet">
<office:body><office:spreadsheet><table:table table:name="S"><table:table-row><table:table-cell><text:p>Hello 世界</text:p></table:table-cell></table:table-row></table:table></office:spreadsheet></office:body>
</office:document>"#;
#[test]
fn a_calc_cut_finishes_without_a_dialog() {
let Some(install) = install() else {
eprintln!("SKIP: no pinned LOK build");
return;
};
let (done, wait) = std::sync::mpsc::channel();
let (pid_out, pid_in) = std::sync::mpsc::channel();
let dir = tempfile::tempdir().expect("a temp dir");
let root = dir.path().to_path_buf();
std::thread::spawn(move || {
let mut worker = Worker::spawn(&install, &root.join("profile")).expect("the worker");
pid_out.send(worker.child.id()).expect("the test waits");
let source = root.join("in.fods");
write(&source, FODS);
let doc = worker
.load(&format!("file://{}", source.display()))
.expect("the sheet loads");
worker
.post_key(&doc, 0, 1027 | 0x1000)
.expect("shift+right selects");
let copied = worker.copy(&doc).expect("copy runs");
worker.cut(&doc).expect("the cut runs");
let out = root.join("out.fods");
doc.save_as(&mut worker, &format!("file://{}", out.display()), "fods")
.expect("the save works");
let xml = std::fs::read_to_string(&out).expect("the save reads");
done.send((copied.len(), xml.contains("Hello")))
.expect("the test waits");
});
let pid = pid_in.recv().expect("the worker starts");
let outcome = wait.recv_timeout(std::time::Duration::from_secs(20));
if outcome.is_err() {
// A blocked worker would hold the test open.
std::process::Command::new("kill")
.args(["-9", &pid.to_string()])
.status()
.expect("kill runs");
}
let (flavors, kept) = outcome.expect("the cut never blocked on a dialog");
assert!(flavors > 0, "the cells copied");
assert!(!kept, "the cut emptied A1");
}
#[test]
fn a_table_survives_copy_and_paste() {
if install().is_none() {
eprintln!("SKIP: no pinned LOK build");
return;
}
let (dir, mut worker, doc) = open(TABLE_FODT);
click(&mut worker, &doc, 150, 150);
worker
.run(&doc, "edit.select_all", &[])
.expect("select all");
worker
.run(&doc, "edit.select_all", &[])
.expect("select the whole text");
let flavors = worker.copy(&doc).expect("copy runs");
let (_, html) = flavors
.iter()
.find(|(mime, _)| mime == "text/html")
.expect("an html flavor");
let html = String::from_utf8_lossy(html);
assert!(
html.contains("<table"),
"the html carries the table: {html}"
);
let target = dir.path().join("b.fodt");
write(&target, FODT);
let other = worker
.load(&format!("file://{}", target.display()))
.expect("b loads");
click(&mut worker, &other, 150, 150);
worker
.paste(&other, "text/html", html.as_bytes())
.expect("the table pastes");
let text = saved(&dir, &mut worker, &other, "b-out.fodt");
assert!(text.contains("<table:table "), "b gained a table: {text}");
assert!(text.contains("left") && text.contains("right"));
}
#[test]
fn an_image_pastes_and_copies_back() {
if install().is_none() {
eprintln!("SKIP: no pinned LOK build");
return;
}
let (dir, mut worker, doc) = open(FODT);
click(&mut worker, &doc, 150, 150);
worker
.paste(&doc, "image/png", &red_png())
.expect("the image pastes");
let text = saved(&dir, &mut worker, &doc, "out.fodt");
assert!(text.contains("<draw:image"), "the image lands: {text}");
let flavors = worker.copy(&doc).expect("copy runs");
let png = flavors
.iter()
.find(|(mime, _)| mime == "image/png")
.map(|(_, data)| data.clone())
.expect("the selected image copies as png");
let back = image::load_from_memory(&png)
.expect("the png decodes")
.to_rgba8();
assert_eq!(back.get_pixel(0, 0).0, [255, 0, 0, 255], "red comes back");
}
#[test]
fn a_foreign_paste_flavor_is_refused() {
if install().is_none() {
eprintln!("SKIP: no pinned LOK build");
return;
}
let (_dir, mut worker, doc) = open(FODT);
assert!(worker.paste(&doc, "application/x-evil", b"x").is_err());
worker.post_key(&doc, 0, 1027).expect("the worker lives");
}
/// HTML carrying `png` as an inline image.
fn inline_html(png: &[u8]) -> String {
use base64::Engine;
let data = base64::engine::general_purpose::STANDARD.encode(png);
format!(r#"<html><body><img src="data:image/png;base64,{data}"></body></html>"#)
}
#[test]
fn inline_images_paste_only_when_they_decode() {
if install().is_none() {
eprintln!("SKIP: no pinned LOK build");
return;
}
let (dir, mut worker, doc) = open(FODT);
click(&mut worker, &doc, 150, 150);
worker
.run(&doc, "edit.select_all", &[])
.expect("select all");
let broken = inline_html(b"this is not a png");
assert!(worker.paste(&doc, "text/html", broken.as_bytes()).is_err());
assert!(
!worker.copy(&doc).expect("copy runs").is_empty(),
"the selection stays"
);
let text = saved(&dir, &mut worker, &doc, "kept.fodt");
assert_eq!(paragraphs(&text), ["Hello 世界"], "the text stays");
worker
.post_key(&doc, 0, 1029)
.expect("End drops the selection");
worker
.paste(&doc, "text/html", inline_html(&red_png()).as_bytes())
.expect("a real inline image pastes");
let text = saved(&dir, &mut worker, &doc, "image.fodt");
assert_eq!(text.matches("<draw:image").count(), 1, "the image lands");
}
#[test]
fn pastes_keep_working_past_the_undo_cap() {
if install().is_none() {
eprintln!("SKIP: no pinned LOK build");
return;
}
let (dir, mut worker, doc) = open(FODT);
click(&mut worker, &doc, 150, 150);
for n in 1..=105 {
worker
.paste(
&doc,
"text/plain;charset=utf-8",
format!("p{n} ").as_bytes(),
)
.unwrap_or_else(|error| panic!("paste {n} failed: {error}"));
}
let text = paragraphs(&saved(&dir, &mut worker, &doc, "out.fodt"));
assert!(
text[0].contains("p105 ") && text[0].contains("p1 "),
"every paste landed"
);
}
@@ -0,0 +1,52 @@
use super::input::FODS;
use super::*;
/// Keys never queue behind a view's event watch.
#[test]
fn keys_never_wait_behind_the_event_watch() {
let Some(install) = install() else {
eprintln!("SKIP: no pinned LOK build");
return;
};
let dir = tempfile::tempdir().unwrap();
let mut worker = Worker::spawn(&install, &dir.path().join("profile")).unwrap();
let source = dir.path().join("in.fods");
write(&source, FODS);
let doc = worker
.load(&format!("file://{}", source.display()))
.unwrap();
let feed = worker.feed();
let shared = std::sync::Arc::new(std::sync::Mutex::new(worker));
let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let poller = {
let (feed, doc, stop) = (feed.clone(), doc.clone(), stop.clone());
std::thread::spawn(move || {
let mut known = 0;
while !stop.load(std::sync::atomic::Ordering::Relaxed) {
known = feed.wait(&doc, known, 50).unwrap().seq;
}
})
};
let mut waits = Vec::new();
let mut calls = Vec::new();
for c in "abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyz".chars() {
let start = std::time::Instant::now();
let mut worker = shared.lock().unwrap();
let locked = start.elapsed();
worker.post_key(&doc, c as u32, 0).unwrap();
drop(worker);
waits.push(locked.as_secs_f64() * 1000.0);
calls.push((start.elapsed() - locked).as_secs_f64() * 1000.0);
std::thread::sleep(std::time::Duration::from_millis(30));
}
stop.store(true, std::sync::atomic::Ordering::Relaxed);
poller.join().unwrap();
let stat = |v: &mut Vec<f64>| {
v.sort_by(|a, b| a.partial_cmp(b).unwrap());
(v[v.len() / 2], v[v.len() * 95 / 100], v[v.len() - 1])
};
let (waited, called) = (stat(&mut waits), stat(&mut calls));
log::info!("keys: lock wait {waited:?} ms, call {called:?} ms");
assert!(waited.1 < 2.0, "keys wait for the lock: {waited:?} ms");
assert!(called.1 < 30.0, "a key round trip is slow: {called:?} ms");
}
+404
View File
@@ -0,0 +1,404 @@
use super::input::{open, saved};
use super::*;
const SETTLE: std::time::Duration = std::time::Duration::from_secs(10);
/// The latest value of `uno` seen so far.
fn state<'a>(seen: &'a Events, uno: &str) -> Option<&'a str> {
seen.states
.iter()
.rev()
.find(|(name, _)| name == uno)
.map(|(_, value)| value.as_str())
}
fn run(worker: &mut Worker, doc: &Doc, id: &str, values: &[&str]) {
worker
.run(doc, id, values)
.unwrap_or_else(|error| panic!("{id}: {error}"));
}
/// Lets the kernel settle a selection, as a person does.
fn pause(worker: &mut Worker, doc: &Doc) {
poll_until(
worker,
doc,
&Events::default(),
std::time::Duration::from_millis(500),
|_| false,
);
}
/// A solid PNG on disk, as a file URL.
pub(super) fn png(dir: &std::path::Path, name: &str, rgba: [u8; 4]) -> String {
let mut bytes = Vec::new();
image::RgbaImage::from_pixel(300, 200, image::Rgba(rgba))
.write_to(
&mut std::io::Cursor::new(&mut bytes),
image::ImageFormat::Png,
)
.expect("the png encodes");
let path = dir.join(name);
std::fs::write(&path, bytes).expect("the png writes");
crate::file_url(&path)
}
#[test]
fn page_setup_lands_and_reports() {
if install().is_none() {
eprintln!("SKIP: no pinned LOK build");
return;
}
let (dir, mut worker, doc) = open(FODT);
click(&mut worker, &doc, 150, 150);
run(&mut worker, &doc, "page.margins_lr", &["1270", "2540"]);
run(&mut worker, &doc, "page.margins_ul", &["2540", "1270"]);
run(&mut worker, &doc, "page.columns", &["2"]);
run(&mut worker, &doc, "page.size", &["21000", "29700"]);
run(
&mut worker,
&doc,
"page.orientation",
&["29700", "21000", "true", "0", "4"],
);
let want = [
(".uno:PageLRMargin", "1270,2540"),
(".uno:PageULMargin", "2540,1270"),
(".uno:PageColumnType", "2"),
(".uno:AttributePage", "true,0,4"),
(".uno:AttributePageSize", "29700 x 21001"),
];
let seen = poll_until(&mut worker, &doc, &Events::default(), SETTLE, |seen| {
want.iter()
.all(|(uno, value)| state(seen, uno) == Some(*value))
});
for (uno, value) in want {
assert_eq!(state(&seen, uno), Some(value), "{uno}");
}
let text = saved(&dir, &mut worker, &doc, "page.fodt");
for mark in [
"fo:page-width=\"11.6929in\" fo:page-height=\"8.2681in\"",
"style:print-orientation=\"landscape\"",
"fo:margin-top=\"1in\" fo:margin-bottom=\"0.5in\" fo:margin-left=\"0.5in\" fo:margin-right=\"1in\"",
"fo:column-count=\"2\"",
] {
assert!(text.contains(mark), "{mark} lands");
}
}
#[test]
fn headers_footers_and_sections_land() {
if install().is_none() {
eprintln!("SKIP: no pinned LOK build");
return;
}
let (dir, mut worker, doc) = open(FODT);
click(&mut worker, &doc, 150, 150);
run(&mut worker, &doc, "header.on", &["true"]);
run(&mut worker, &doc, "footer.on", &["true"]);
run(&mut worker, &doc, "header.layout", &["3"]);
run(&mut worker, &doc, "field.page", &[]);
run(&mut worker, &doc, "field.pages", &[]);
let want = [
(".uno:SwPageHeaderItem", "true"),
(".uno:SwPageFooterItem", "true"),
(".uno:SwPageHeaderLayout", "3"),
];
let seen = poll_until(&mut worker, &doc, &Events::default(), SETTLE, |seen| {
want.iter()
.all(|(uno, value)| state(seen, uno) == Some(*value))
});
for (uno, value) in want {
assert_eq!(state(&seen, uno), Some(value), "{uno}");
}
run(&mut worker, &doc, "page.section", &["3", "Landscape"]);
let section = poll_until(&mut worker, &doc, &seen, SETTLE, |seen| {
state(seen, ".uno:PageStyleName") == Some("Landscape")
});
assert_eq!(state(&section, ".uno:PageStyleName"), Some("Landscape"));
run(&mut worker, &doc, "header.on", &["false"]);
let text = saved(&dir, &mut worker, &doc, "header.fodt");
for mark in [
"<style:header>",
"<style:header-left>",
"<style:header-first>",
"<style:footer>",
"style:master-page-name=\"Landscape\"",
"<text:page-number",
"<text:page-count",
] {
assert!(text.contains(mark), "{mark} lands");
}
let landscape = text
.split("<style:master-page style:name=\"Landscape\"")
.nth(1)
.and_then(|rest| rest.split("</office:master-styles>").next())
.expect("the section's page style");
assert!(
!landscape.contains("<style:header>"),
"the section's header is off"
);
}
#[test]
fn tables_edit_and_land() {
if install().is_none() {
eprintln!("SKIP: no pinned LOK build");
return;
}
let (dir, mut worker, doc) = open(FODT);
click(&mut worker, &doc, 150, 150);
worker.post_key(&doc, 0, 1029).expect("end");
worker.post_key(&doc, 13, 1280).expect("enter");
run(&mut worker, &doc, "table.insert", &["3", "2"]);
let inside = poll_until(&mut worker, &doc, &Events::default(), SETTLE, |seen| {
state(seen, ".uno:DeleteTable") == Some("enabled")
});
assert_eq!(
state(&inside, ".uno:DeleteTable"),
Some("enabled"),
"in a table"
);
run(&mut worker, &doc, "table.repeat_heading", &[]);
for (id, values) in [
("table.row_below", vec![]),
("table.row_above", vec![]),
("table.delete_rows", vec![]),
("table.column_right", vec![]),
("table.column_left", vec![]),
("table.delete_columns", vec![]),
("table.shading", vec!["16776960"]),
("table.row_split", vec!["false"]),
("table.split", vec!["2", "false"]),
("table.select", vec![]),
("table.borders_all", vec![]),
("table.even_columns", vec![]),
("table.optimal_height", vec![]),
("table.even_rows", vec![]),
] {
run(&mut worker, &doc, id, &values);
}
let repeated = poll_until(&mut worker, &doc, &inside, SETTLE, |seen| {
state(seen, ".uno:HeadingRowsRepeat") == Some("true")
});
assert_eq!(state(&repeated, ".uno:HeadingRowsRepeat"), Some("true"));
let text = saved(&dir, &mut worker, &doc, "table.fodt");
for mark in [
"<table:table-header-rows>",
"fo:background-color=\"#ffff00\"",
"fo:keep-together=\"always\"",
"fo:border=\"0.5pt solid #000000\"",
"table:number-columns-spanned=\"2\"",
] {
assert!(text.contains(mark), "{mark} lands");
}
run(&mut worker, &doc, "table.borders_none", &[]);
run(&mut worker, &doc, "table.merge", &[]);
run(&mut worker, &doc, "table.delete", &[]);
let text = saved(&dir, &mut worker, &doc, "gone.fodt");
assert!(!text.contains("<table:table "), "the table is gone");
}
#[test]
fn images_insert_scale_wrap_crop_and_stack() {
if install().is_none() {
eprintln!("SKIP: no pinned LOK build");
return;
}
let (dir, mut worker, doc) = open(FODT);
let (blue, red, green) = (
png(dir.path(), "blue.png", [0, 0, 255, 255]),
png(dir.path(), "red.png", [255, 0, 0, 255]),
png(dir.path(), "green.png", [0, 255, 0, 255]),
);
click(&mut worker, &doc, 150, 150);
run(&mut worker, &doc, "image.insert", &[&blue]);
run(&mut worker, &doc, "image.insert", &[&red]);
pause(&mut worker, &doc);
run(&mut worker, &doc, "image.size", &["2835", "1890"]);
run(&mut worker, &doc, "image.anchor_paragraph", &[]);
run(&mut worker, &doc, "image.wrap_through", &[]);
let want = [
(".uno:SetAnchorToPara", "true"),
(".uno:WrapThrough", "true"),
];
let framed = poll_until(&mut worker, &doc, &Events::default(), SETTLE, |seen| {
want.iter()
.all(|(uno, value)| state(seen, uno) == Some(*value))
});
for (uno, value) in want {
assert_eq!(state(&framed, uno), Some(value), "{uno}");
}
let (x, y, w, h) = framed.graphic.expect("the image is selected");
worker.post_key(&doc, 0, 1281).expect("escape");
let cleared = poll_until(&mut worker, &doc, &framed, SETTLE, |seen| {
seen.graphic.is_none()
});
click(&mut worker, &doc, x + w / 2, y + h / 2);
let picked = poll_until(&mut worker, &doc, &cleared, SETTLE, |seen| {
seen.graphic.is_some()
});
assert!(picked.graphic.is_some(), "the click selects the image");
pause(&mut worker, &doc);
run(&mut worker, &doc, "image.crop", &[]);
pause(&mut worker, &doc);
worker
.drag_graphic(&doc, (x + w, y + h), (x + w - w / 3, y + h - h / 3))
.expect("the handle drags");
let text = saved(&dir, &mut worker, &doc, "image.fodt");
assert_eq!(
text.matches("<draw:frame").count(),
1,
"the insert replaced it"
);
assert!(text.contains("style:wrap=\"run-through\""), "wrap lands");
assert!(
text.contains("text:anchor-type=\"paragraph\""),
"anchor lands"
);
assert!(
!text.contains("fo:clip=\"rect(0in, 0in, 0in, 0in)\""),
"the drag crops"
);
worker.post_key(&doc, 0, 1281).expect("escape");
click(&mut worker, &doc, 150, 150);
poll_until(&mut worker, &doc, &framed, SETTLE, |_| false);
run(&mut worker, &doc, "image.insert", &[&green]);
pause(&mut worker, &doc);
run(&mut worker, &doc, "image.wrap_through", &[]);
run(&mut worker, &doc, "image.back", &[]);
let text = saved(&dir, &mut worker, &doc, "stack.fodt");
let frames: Vec<&str> = text.split("<draw:frame").skip(1).collect();
assert_eq!(frames.len(), 2, "two images");
let back = frames
.iter()
.find(|frame| frame.contains("draw:z-index=\"0\""))
.expect("one at the back");
assert!(back.contains("Image2"), "the new image went back");
}
#[test]
fn the_remaining_layout_rows_reach_the_kernel() {
if install().is_none() {
eprintln!("SKIP: no pinned LOK build");
return;
}
let (dir, mut worker, doc) = open(FODT);
let (blue, green) = (
png(dir.path(), "blue.png", [0, 0, 255, 255]),
png(dir.path(), "green.png", [0, 255, 0, 255]),
);
click(&mut worker, &doc, 150, 150);
run(&mut worker, &doc, "page.break", &[]);
run(&mut worker, &doc, "image.insert", &[&blue]);
pause(&mut worker, &doc);
for id in [
"image.anchor_page",
"image.anchor_char",
"image.wrap_off",
"image.wrap_on",
"image.wrap_ideal",
"image.wrap_left",
"image.wrap_right",
] {
run(&mut worker, &doc, id, &[]);
}
worker.post_key(&doc, 0, 1281).expect("escape");
click(&mut worker, &doc, 150, 150);
pause(&mut worker, &doc);
run(&mut worker, &doc, "image.insert", &[&green]);
pause(&mut worker, &doc);
run(&mut worker, &doc, "image.wrap_through", &[]);
for id in [
"image.backward",
"image.forward",
"image.back",
"image.front",
] {
run(&mut worker, &doc, id, &[]);
}
run(&mut worker, &doc, "image.anchor_as_char", &[]);
let text = saved(&dir, &mut worker, &doc, "rest.fodt");
assert!(
text.contains("fo:break-before=\"page\""),
"the page break lands"
);
assert_eq!(text.matches("<draw:frame").count(), 2, "both images stay");
assert!(
text.contains("text:anchor-type=\"as-char\""),
"as-char lands"
);
}
#[test]
fn turning_the_page_undoes_in_one_step() {
if install().is_none() {
eprintln!("SKIP: no pinned LOK build");
return;
}
let (dir, mut worker, doc) = open(FODT);
click(&mut worker, &doc, 150, 150);
run(
&mut worker,
&doc,
"page.orientation",
&["27940", "21590", "true", "0", "4"],
);
let text = saved(&dir, &mut worker, &doc, "turned.fodt");
assert!(text.contains("fo:page-width=\"11in\" fo:page-height=\"8.5in\""));
assert!(text.contains("style:print-orientation=\"landscape\""));
run(&mut worker, &doc, "edit.undo", &[]);
let text = saved(&dir, &mut worker, &doc, "back.fodt");
assert!(
text.contains("fo:page-width=\"8.5in\" fo:page-height=\"11in\""),
"size back"
);
assert!(
text.contains("style:print-orientation=\"portrait\""),
"flag back"
);
}
#[test]
fn a_section_keeps_its_own_page_style() {
if install().is_none() {
eprintln!("SKIP: no pinned LOK build");
return;
}
let (dir, mut worker, doc) = open(FODT);
click(&mut worker, &doc, 150, 150);
worker.post_key(&doc, 0, 1029).expect("end");
run(&mut worker, &doc, "style.new", &["Section 2", "8"]);
run(&mut worker, &doc, "page.section", &["3", "Section 2"]);
run(&mut worker, &doc, "page.columns", &["2"]);
run(&mut worker, &doc, "footer.on", &["true"]);
run(&mut worker, &doc, "footer.layout", &["2"]);
let seen = poll_until(&mut worker, &doc, &Events::default(), SETTLE, |seen| {
state(seen, ".uno:SwPageFooterLayout") == Some("2")
});
assert_eq!(state(&seen, ".uno:PageStyleName"), Some("Section 2"));
let text = saved(&dir, &mut worker, &doc, "section.fodt");
assert_eq!(
text.matches("fo:column-count=\"2\"").count(),
1,
"one section has columns"
);
let section = text
.split("style:display-name=\"Section 2\"")
.nth(1)
.and_then(|rest| rest.split("</style:master-page>").next())
.expect("the section's master page");
assert!(
section.contains("<style:footer-left>"),
"its footer differs on even pages"
);
let standard = text
.split("<style:master-page style:name=\"Standard\"")
.nth(1)
.and_then(|rest| rest.split("<style:master-page").next())
.expect("the first section's master page");
assert!(
!standard.contains("<style:footer"),
"the first section keeps no footer"
);
}
Binary file not shown.
+79
View File
@@ -0,0 +1,79 @@
use super::analysis::steps;
use super::show::open_deck;
use super::*;
use crate::show;
fn clip() -> std::path::PathBuf {
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../tests/fixtures/media/clip.mp4")
}
fn media(worker: &mut Worker, doc: &Doc) -> Vec<show::Media> {
show::media(&worker.values(doc, show::MEDIA).expect("reads")).expect("parses")
}
fn insert(worker: &mut Worker, doc: &Doc, path: &std::path::Path, link: bool) -> io::Result<()> {
let url = crate::file_url(path);
worker.run(
doc,
"media.insert",
&[&url, &link.to_string(), "8000", "4500"],
)
}
#[test]
fn embedded_media_goes_in_mid_slide_and_survives_a_save() {
if install().is_none() {
eprintln!("SKIP: no pinned LOK build");
return;
}
let (dir, mut worker, doc) = open_deck();
let before = steps(&mut worker, &doc);
insert(&mut worker, &doc, &clip(), false).expect("inserts");
assert_eq!(steps(&mut worker, &doc), before + 1);
let list = media(&mut worker, &doc);
assert_eq!(list.len(), 1);
assert!(!list[0].link && !list[0].missing, "{:?}", list[0]);
assert!(
list[0].url.starts_with("vnd.sun.star.Package:"),
"{}",
list[0].url
);
let objects =
show::objects(&worker.values(&doc, show::OBJECTS).expect("reads")).expect("parses");
let object = &objects.objects[list[0].index];
assert_eq!(object.kind, "media");
let out = dir.path().join("media.odp");
doc.save_as(&mut worker, &crate::file_url(&out), "odp")
.expect("saves");
let again = worker.load(&crate::file_url(&out)).expect("loads");
let list = media(&mut worker, &again);
assert_eq!(list.len(), 1);
assert!(
!list[0].link && !list[0].missing,
"the copy plays after a reload: {:?}",
list[0]
);
}
#[test]
fn a_linked_file_that_goes_away_reads_as_missing() {
if install().is_none() {
eprintln!("SKIP: no pinned LOK build");
return;
}
let (dir, mut worker, doc) = open_deck();
let copy = dir.path().join("linked.mp4");
std::fs::copy(clip(), &copy).expect("copies");
insert(&mut worker, &doc, &copy, true).expect("inserts");
let list = media(&mut worker, &doc);
assert!(list[0].link && !list[0].missing, "{:?}", list[0]);
std::fs::remove_file(&copy).expect("removes");
assert!(media(&mut worker, &doc)[0].missing);
let before = steps(&mut worker, &doc);
let gone = dir.path().join("never.mp4");
assert!(
insert(&mut worker, &doc, &gone, false).is_err(),
"nothing to embed"
);
assert_eq!(steps(&mut worker, &doc), before);
}
+222
View File
@@ -0,0 +1,222 @@
use super::analysis::steps;
use super::show::open_deck;
use super::*;
use crate::show::{self, Transition};
fn transition(worker: &mut Worker, doc: &Doc) -> Transition {
let read = worker.values(doc, show::TRANSITION).expect("reads");
show::transition(&read).expect("parses")
}
fn effects(worker: &mut Worker, doc: &Doc) -> Vec<show::Effect> {
let read = worker.values(doc, show::ANIMATIONS).expect("reads");
show::effects(&read).expect("parses")
}
fn fade() -> Transition {
Transition {
preset: "fade-smoothly".into(),
duration: 1.5,
auto: true,
time: 4.0,
}
}
#[test]
fn transitions_set_read_and_undo_as_one_step() {
if install().is_none() {
eprintln!("SKIP: no pinned LOK build");
return;
}
let (_dir, mut worker, doc) = open_deck();
assert_eq!(transition(&mut worker, &doc).preset, "");
let before = steps(&mut worker, &doc);
let json = show::transition_json(&fade(), false);
worker
.run(&doc, "slide.transition", &[&json])
.expect("sets");
assert_eq!(transition(&mut worker, &doc), fade());
assert_eq!(steps(&mut worker, &doc), before + 1);
worker.run(&doc, "edit.undo", &[]).expect("undoes");
assert_eq!(transition(&mut worker, &doc).preset, "");
// Every offered preset goes in and reads back.
for preset in show::TRANSITIONS {
let one = Transition {
preset: preset.into(),
..fade()
};
let json = show::transition_json(&one, false);
worker
.run(&doc, "slide.transition", &[&json])
.expect("sets");
assert_eq!(transition(&mut worker, &doc).preset, preset);
}
let push = Transition {
preset: "push-left".into(),
..fade()
};
let json = show::transition_json(&push, true);
worker
.run(&doc, "slide.transition", &[&json])
.expect("sets all");
doc.show_sheet(&mut worker, 1).expect("the second slide");
assert_eq!(transition(&mut worker, &doc).preset, "push-left");
let before = steps(&mut worker, &doc);
for bad in [
r#"{"preset":"no-such-thing"}"#,
r#"{"preset":"dissolve","duration":0}"#,
r#"{"preset":"dissolve","advance":"later"}"#,
r#"{"preset":"dissolve","duration":"2"}"#,
r#"{"preset":"dissolve","all":"yes"}"#,
] {
assert!(
worker.run(&doc, "slide.transition", &[bad]).is_err(),
"{bad}"
);
}
assert_eq!(steps(&mut worker, &doc), before);
}
#[test]
fn effects_add_change_move_and_go_as_single_steps() {
if install().is_none() {
eprintln!("SKIP: no pinned LOK build");
return;
}
let (_dir, mut worker, doc) = open_deck();
worker
.run(&doc, "draw.shape", &["rectangle", "true"])
.expect("draws");
let objects =
show::objects(&worker.values(&doc, show::OBJECTS).expect("reads")).expect("parses");
let rect = objects
.objects
.iter()
.position(|o| o.selected)
.expect("the new shape");
let add = |worker: &mut Worker, preset: &str| {
let json = serde_json::json!({ "preset": preset }).to_string();
worker.run(&doc, "animation.add", &[&json]).expect("adds");
};
let before = steps(&mut worker, &doc);
add(&mut worker, "ooo-entrance-fade-in");
assert_eq!(steps(&mut worker, &doc), before + 1);
let list = effects(&mut worker, &doc);
assert_eq!(list.len(), 1);
let first = &list[0];
assert_eq!(
(
first.preset.as_str(),
first.class.as_str(),
first.target,
first.start.as_str()
),
("ooo-entrance-fade-in", "entrance", Some(rect), "click")
);
assert!(!first.hash.is_empty());
add(&mut worker, "ooo-entrance-fly-in");
let change = r#"{"index":1,"start":"after","duration":1.25,"delay":0.5}"#;
let before = steps(&mut worker, &doc);
worker
.run(&doc, "animation.change", &[change])
.expect("changes");
assert_eq!(steps(&mut worker, &doc), before + 1);
let second = effects(&mut worker, &doc)[1].clone();
assert_eq!(
(second.start.as_str(), second.duration, second.delay),
("after", 1.25, 0.5)
);
worker
.run(&doc, "animation.change", &[r#"{"index":1,"move":-1}"#])
.expect("moves");
let order: Vec<String> = effects(&mut worker, &doc)
.into_iter()
.map(|e| e.preset)
.collect();
assert_eq!(order, ["ooo-entrance-fly-in", "ooo-entrance-fade-in"]);
worker
.run(&doc, "animation.change", &[r#"{"index":0,"remove":true}"#])
.expect("removes");
assert_eq!(effects(&mut worker, &doc).len(), 1);
worker.run(&doc, "edit.undo", &[]).expect("undoes");
assert_eq!(
effects(&mut worker, &doc).len(),
2,
"the removal comes back"
);
// Every offered effect goes in.
for preset in show::EFFECTS {
add(&mut worker, preset);
assert_eq!(
effects(&mut worker, &doc).last().expect("one").preset,
preset
);
}
let before = steps(&mut worker, &doc);
for (id, bad) in [
("animation.add", r#"{"preset":"no-such-effect"}"#),
(
"animation.add",
r#"{"preset":"ooo-entrance-fade-in","duration":0}"#,
),
("animation.change", r#"{"index":99,"duration":1}"#),
("animation.change", r#"{"index":0,"start":"later"}"#),
("animation.change", r#"{"index":0,"move":-1}"#),
("animation.change", r#"{"index":0,"remove":"yes"}"#),
] {
assert!(worker.run(&doc, id, &[bad]).is_err(), "{bad}");
}
assert_eq!(steps(&mut worker, &doc), before);
}
#[test]
fn an_animated_object_is_its_own_layer_under_its_effect_hash() {
if install().is_none() {
eprintln!("SKIP: no pinned LOK build");
return;
}
let (_dir, mut worker, doc) = open_deck();
worker
.run(&doc, "draw.shape", &["rectangle", "true"])
.expect("draws");
let json = serde_json::json!({ "preset": "ooo-entrance-fade-in" }).to_string();
worker.run(&doc, "animation.add", &[&json]).expect("adds");
let hash = effects(&mut worker, &doc)[0].hash.clone();
let layers = doc
.slide_layers(&mut worker, 0, (480, 270))
.expect("layers");
assert_eq!(layers.size, (480, 270));
let moving: Vec<(&str, bool, i64)> = (layers.list.iter())
.filter_map(|layer| match &layer.part {
show::Part::Moving {
hash,
visible,
bounds,
} => Some((hash.as_str(), *visible, bounds.2)),
_ => None,
})
.collect();
assert_eq!(moving.len(), 1);
assert_eq!(
(moving[0].0, moving[0].1),
(hash.as_str(), false),
"an entrance starts hidden"
);
assert!(moving[0].2 > 0, "it has a width");
let still = layers
.list
.iter()
.filter(|l| l.part == show::Part::Still)
.count();
assert!(still >= 2, "background and the rest");
let bytes = 480 * 270 * 4;
assert!(
layers
.list
.iter()
.all(|l| l.pixels.len() == bytes)
);
}
+194
View File
@@ -0,0 +1,194 @@
use super::input::open;
use super::*;
use crate::review;
/// Three pages, two headings, fonts the system lacks.
const LONG: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
<office:document xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0" xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0" office:version="1.3" office:mimetype="application/vnd.oasis.opendocument.text">
<office:font-face-decls><style:font-face style:name="Nowhere Sans" svg:font-family="&apos;Nowhere Sans&apos;" style:font-family-generic="swiss"/><style:font-face style:name="Nowhere Pair" svg:font-family="&apos;Nowhere Sans;Nowhere Serif&apos;"/></office:font-face-decls>
<office:automatic-styles>
<style:style style:name="P1" style:family="paragraph"><style:paragraph-properties fo:break-before="page"/></style:style>
<style:style style:name="T1" style:family="text"><style:text-properties style:font-name="Nowhere Sans"/></style:style>
<style:style style:name="T2" style:family="text"><style:text-properties style:font-name="Nowhere Pair"/></style:style>
</office:automatic-styles>
<office:body><office:text>
<text:h text:outline-level="1">First part</text:h><text:p>alpha <text:span text:style-name="T1">odd font</text:span> <text:span text:style-name="T2">pair</text:span></text:p>
<text:h text:style-name="P1" text:outline-level="2">Second part</text:h><text:p>bravo</text:p>
<text:p text:style-name="P1">charlie</text:p>
</office:text></office:body>
</office:document>"#;
/// The text of a PDF, if pdftotext is present.
pub(super) fn pdf_text(path: &std::path::Path) -> Option<String> {
let out = std::process::Command::new("pdftotext")
.arg(path)
.arg("-")
.output()
.ok()?;
assert!(out.status.success(), "pdftotext reads {}", path.display());
Some(String::from_utf8_lossy(&out.stdout).into_owned())
}
#[test]
fn a_locked_document_asks_for_its_password() {
if install().is_none() {
eprintln!("SKIP: no pinned LOK build");
return;
}
let dir = tempfile::tempdir().expect("a temp dir");
let mut worker =
Worker::spawn(&install().expect("lok"), &dir.path().join("profile")).expect("the worker");
let locked =
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/client/tests/locked.odt");
let url = crate::file_url(&locked);
for password in ["", "wrong"] {
let error = worker
.unlock(&url, password)
.err()
.expect("a locked load fails");
assert_eq!(
error.kind(),
std::io::ErrorKind::PermissionDenied,
"{password}: {error}"
);
}
let doc = worker
.unlock(&url, "secret")
.expect("the right password opens it");
let out = dir.path().join("open.fodt");
doc.save_as(&mut worker, &crate::file_url(&out), "fodt")
.expect("saves");
assert!(
std::fs::read_to_string(&out)
.expect("reads")
.contains("Teh cat")
);
}
#[test]
fn pdfs_take_pages_and_the_outline_has_places() {
if install().is_none() {
eprintln!("SKIP: no pinned LOK build");
return;
}
let (dir, mut worker, doc) = open(LONG);
let seen = poll_until(
&mut worker,
&doc,
&Events::default(),
std::time::Duration::from_secs(5),
|seen| !seen.fonts.is_empty(),
);
let mut fonts = seen.fonts.clone();
fonts.sort();
assert_eq!(
fonts,
["Nowhere Sans", "Nowhere Sans;Nowhere Serif"],
"a declared list is named too"
);
let outline = review::outline(&worker.values(&doc, ".uno:Outline").expect("the outline"))
.expect("the outline parses");
let texts: Vec<_> = outline
.iter()
.map(|heading| (heading.text.as_str(), heading.level))
.collect();
assert_eq!(texts, [("First part", 0), ("Second part", 1)]);
assert!(
outline[0].current && !outline[1].current,
"the cursor sits under the first"
);
assert!(
outline[1].rect.1 > outline[0].rect.1,
"the second heading sits lower"
);
let pdf = dir.path().join("two.pdf");
let only_two = crate::Pdf {
pages: Some((2, 2)),
..crate::Pdf::default()
};
doc.export_pdf(&mut worker, &crate::file_url(&pdf), &only_two)
.expect("the export works");
let backwards = crate::Pdf {
pages: Some((3, 2)),
..crate::Pdf::default()
};
assert!(
doc.export_pdf(&mut worker, &crate::file_url(&pdf), &backwards)
.is_err()
);
let Some(text) = pdf_text(&pdf) else {
eprintln!("SKIP: no pdftotext to read the PDF");
return;
};
assert!(
text.contains("bravo") && !text.contains("alpha") && !text.contains("charlie"),
"{text}"
);
}
#[test]
fn comments_and_changes_print_as_chosen() {
if install().is_none() {
eprintln!("SKIP: no pinned LOK build");
return;
}
let (dir, mut worker, doc) = open(FODT);
click(&mut worker, &doc, 150, 150);
worker
.run(&doc, "comment.insert", &["Marginal"])
.expect("a comment");
worker.run(&doc, "track.toggle", &[]).expect("tracking");
for _ in 0.."Hello".len() {
worker.post_key(&doc, 0, 1286).expect("delete");
}
let mut texts = Vec::new();
for (n, (comments, changes)) in [(false, false), (true, true)].into_iter().enumerate() {
let pdf = dir.path().join(format!("{n}.pdf"));
let options = crate::Pdf {
pages: None,
comments,
changes,
handouts: 0,
};
doc.export_pdf(&mut worker, &crate::file_url(&pdf), &options)
.expect("the export works");
let Some(text) = pdf_text(&pdf) else {
eprintln!("SKIP: no pdftotext to read the PDF");
return;
};
texts.push(text);
}
assert!(
!texts[0].contains("Marginal"),
"comments stay out: {}",
texts[0]
);
assert!(
texts[1].contains("Marginal"),
"comments go in the margin: {}",
texts[1]
);
assert!(
!texts[0].contains("Hello"),
"the final text drops the deletion"
);
assert!(texts[1].contains("Hello"), "marked changes keep it");
}
#[test]
fn a_bookmark_knows_its_place() {
if install().is_none() {
eprintln!("SKIP: no pinned LOK build");
return;
}
let (_dir, mut worker, doc) = open(LONG);
let pages = doc.pages(&mut worker).expect("pages");
click(&mut worker, &doc, pages[2].0 + 1500, pages[2].1 + 1500);
worker
.run(&doc, "bookmark.insert", &["Far"])
.expect("a mark");
let marks = review::bookmarks(&worker.values(&doc, ".uno:Bookmarks").expect("the marks"))
.expect("the marks parse");
let place = marks[0].rect.expect("the mark has a place");
assert!(place.1 > pages[2].1, "the mark sits on page three");
}
+385
View File
@@ -0,0 +1,385 @@
use super::*;
/// How long an edit may take to show.
const SETTLE: std::time::Duration = std::time::Duration::from_secs(3);
/// How long a quiet document is watched.
const QUIET: std::time::Duration = std::time::Duration::from_millis(600);
#[test]
fn a_tile_paints_real_pixels() {
let Some(install) = install() else {
eprintln!("SKIP: no pinned LOK build");
return;
};
let dir = tempfile::tempdir().expect("a temp dir");
let mut worker = Worker::spawn(&install, &dir.path().join("profile")).expect("the worker");
let source = dir.path().join("note.fodt");
write(&source, FODT);
let doc = worker
.load(&format!("file://{}", source.display()))
.expect("the document loads");
let tile = doc
.paint_tile(&mut worker, (0, 0, 960, 960), (64, 64))
.expect("the tile paints");
assert_eq!(tile.pixels.len(), 64 * 64 * 4, "a 64x64 BGRA tile");
let ink = |px: &[u8]| u32::from(px[0]) + u32::from(px[1]) + u32::from(px[2]);
let dark = tile
.pixels
.chunks_exact(4)
.filter(|px| ink(px) < 720)
.count();
let light = tile
.pixels
.chunks_exact(4)
.filter(|px| ink(px) > 720)
.count();
assert!(
dark > 0 && light > 0,
"a text tile has dark and light pixels: {dark}/{light}"
);
worker.close_doc(doc).expect("the document closes");
}
#[test]
fn an_edit_bumps_the_generation_and_invalidates() {
let Some(install) = install() else {
eprintln!("SKIP: no pinned LOK build");
return;
};
let dir = tempfile::tempdir().expect("a temp dir");
let mut worker = Worker::spawn(&install, &dir.path().join("profile")).expect("the worker");
let source = dir.path().join("note.fodt");
write(&source, FODT);
let doc = worker
.load(&format!("file://{}", source.display()))
.expect("the document loads");
let base = poll_until(&mut worker, &doc, &Events::default(), QUIET, |_| false);
let before = doc
.paint_tile(&mut worker, (0, 960, 4000, 2000), (64, 64))
.expect("the tile paints");
assert_eq!(before.generation, base.generation, "idle: no bump");
click(&mut worker, &doc, 150, 150);
worker.post_key(&doc, 0, 1280).expect("Return posts");
let batch = poll_until(&mut worker, &doc, &base, SETTLE, |seen| {
seen.generation > base.generation
});
assert!(
batch.generation > before.generation,
"the edit bumps the generation: {} -to {}",
before.generation,
batch.generation
);
assert!(
!batch.all && !batch.rects.is_empty(),
"the edit invalidates precisely: {batch:?}"
);
let after = doc
.paint_tile(&mut worker, (0, 960, 4000, 2000), (64, 64))
.expect("the tile repaints");
assert!(
after.generation >= batch.generation,
"a fresh tile matches the new generation"
);
assert_ne!(before.pixels, after.pixels, "the edit changes the picture");
worker.close_doc(doc).expect("the document closes");
}
#[test]
fn uno_commands_invalidate_and_repaint() {
let Some(install) = install() else {
eprintln!("SKIP: no pinned LOK build");
return;
};
let dir = tempfile::tempdir().expect("a temp dir");
let mut worker = Worker::spawn(&install, &dir.path().join("profile")).expect("the worker");
let source = dir.path().join("note.fodt");
write(&source, FODT);
let doc = worker
.load(&format!("file://{}", source.display()))
.expect("the document loads");
let base = poll_until(&mut worker, &doc, &Events::default(), QUIET, |_| false);
let before = doc
.paint_tile(&mut worker, (0, 960, 4000, 2000), (64, 64))
.expect("the tile paints");
click(&mut worker, &doc, 150, 150);
worker
.run(&doc, "edit.select_all", &[])
.expect("select all");
worker.cut(&doc).expect("the cut runs");
let batch = poll_until(&mut worker, &doc, &base, SETTLE, |seen| {
seen.generation > base.generation
});
assert!(
batch.generation > base.generation,
"a deleting command bumps the generation"
);
let after = doc
.paint_tile(&mut worker, (0, 960, 4000, 2000), (64, 64))
.expect("the tile repaints");
assert_ne!(
before.pixels, after.pixels,
"the delete changes the picture"
);
worker.close_doc(doc).expect("the document closes");
}
const RED_HTML: &str =
"<html><body><p><span style=\"color:#FF0000\">red red red red</span></p></body></html>";
#[test]
fn a_red_span_paints_red_bytes() {
let Some(install) = install() else {
eprintln!("SKIP: no pinned LOK build");
return;
};
let dir = tempfile::tempdir().expect("a temp dir");
let mut worker = Worker::spawn(&install, &dir.path().join("profile")).expect("the worker");
let source = dir.path().join("red.html");
write(&source, RED_HTML);
let doc = worker
.load(&format!("file://{}", source.display()))
.expect("the document loads");
let tile = doc
.paint_tile(&mut worker, (0, 0, 2000, 1000), (64, 64))
.expect("the tile paints");
let red = tile
.pixels
.chunks_exact(4)
.filter(|px| px[2] > 200 && px[0] < 80 && px[1] < 80)
.count();
assert!(red > 0, "BGRA red bytes paint red: {red}");
worker.close_doc(doc).expect("the document closes");
}
#[test]
fn a_key_press_edits_the_document() {
let Some(install) = install() else {
eprintln!("SKIP: no pinned LOK build");
return;
};
let dir = tempfile::tempdir().expect("a temp dir");
let mut worker = Worker::spawn(&install, &dir.path().join("profile")).expect("the worker");
let source = dir.path().join("note.fodt");
write(&source, FODT);
let doc = worker
.load(&format!("file://{}", source.display()))
.expect("the document loads");
click(&mut worker, &doc, 150, 150);
worker.post_key(&doc, 0, 1280).expect("Return posts");
let out = dir.path().join("after.fodt");
doc.save_as(&mut worker, &format!("file://{}", out.display()), "fodt")
.expect("the save works");
let before = std::fs::read_to_string(&source).expect("the source reads");
let after = std::fs::read_to_string(&out).expect("the save reads");
let count = |text: &str| text.matches("<text:p").count();
assert_eq!(count(&before) + 1, count(&after), "Return adds a paragraph");
worker.close_doc(doc).expect("the document closes");
}
#[test]
fn selection_only_requests_keep_the_generation() {
let Some(install) = install() else {
eprintln!("SKIP: no pinned LOK build");
return;
};
let dir = tempfile::tempdir().expect("a temp dir");
let mut worker = Worker::spawn(&install, &dir.path().join("profile")).expect("the worker");
let source = dir.path().join("note.fodt");
write(&source, FODT);
let doc = worker
.load(&format!("file://{}", source.display()))
.expect("the document loads");
let base = poll_until(&mut worker, &doc, &Events::default(), QUIET, |_| false);
click(&mut worker, &doc, 150, 150);
worker.post_key(&doc, 0, 1027).expect("Right posts");
let quiet = poll_until(&mut worker, &doc, &base, QUIET, |_| false);
assert_eq!(
quiet.generation, base.generation,
"selection moves stay quiet"
);
worker.close_doc(doc).expect("the document closes");
}
#[test]
fn bad_tile_geometry_is_an_error_and_the_worker_lives() {
let Some(install) = install() else {
eprintln!("SKIP: no pinned LOK build");
return;
};
let dir = tempfile::tempdir().expect("a temp dir");
let mut worker = Worker::spawn(&install, &dir.path().join("profile")).expect("the worker");
let source = dir.path().join("note.fodt");
write(&source, FODT);
let doc = worker
.load(&format!("file://{}", source.display()))
.expect("the document loads");
for (x, y, w, h, pw, ph) in [
(0, 0, 0, 960, 64, 64),
(0, 0, -15, 960, 64, 64),
(0, 0, 960, 960, 0, 64),
(0, 0, 960, 960, 100_000, 100_000),
] {
assert!(
doc.paint_tile(&mut worker, (x, y, w, h), (pw, ph)).is_err(),
"geometry {x},{y},{w},{h},{pw},{ph} is refused"
);
}
let (width, height) = doc.size(&mut worker).expect("the worker still answers");
assert!(
width > 0 && height > 0,
"the document outlives bad requests"
);
worker.close_doc(doc).expect("the document closes");
}
#[test]
fn a_long_wait_is_clamped_and_requests_flow() {
let Some(install) = install() else {
eprintln!("SKIP: no pinned LOK build");
return;
};
let dir = tempfile::tempdir().expect("a temp dir");
let mut worker = Worker::spawn(&install, &dir.path().join("profile")).expect("the worker");
let source = dir.path().join("note.fodt");
write(&source, FODT);
let doc = worker
.load(&format!("file://{}", source.display()))
.expect("the document loads");
let started = std::time::Instant::now();
let batch = worker
.poll_events(&doc, u64::MAX, u64::MAX)
.expect("the long wait returns");
assert_eq!(batch.generation, 0, "nothing to drain");
assert!(
started.elapsed() < std::time::Duration::from_secs(4),
"the wait is clamped"
);
worker.post_key(&doc, 0, 1027).expect("Right posts");
worker.close_doc(doc).expect("the document closes");
worker.shutdown().expect("the worker shuts down");
}
#[test]
fn polling_times_out_with_the_same_generation() {
let Some(install) = install() else {
eprintln!("SKIP: no pinned LOK build");
return;
};
let dir = tempfile::tempdir().expect("a temp dir");
let mut worker = Worker::spawn(&install, &dir.path().join("profile")).expect("the worker");
let source = dir.path().join("note.fodt");
write(&source, FODT);
let doc = worker
.load(&format!("file://{}", source.display()))
.expect("the document loads");
let base = poll_until(&mut worker, &doc, &Events::default(), QUIET, |_| false);
let again = poll_until(&mut worker, &doc, &base, QUIET, |_| false);
assert_eq!(again.generation, base.generation, "no edit, no bump");
assert!(again.rects.is_empty() && !again.all, "nothing invalidated");
worker.close_doc(doc).expect("the document closes");
}
#[test]
fn uno_commands_reach_their_own_document() {
let Some(install) = install() else {
eprintln!("SKIP: no pinned LOK build");
return;
};
let dir = tempfile::tempdir().expect("a temp dir");
let mut worker = Worker::spawn(&install, &dir.path().join("profile")).expect("the worker");
let (first, second) = (dir.path().join("a.fodt"), dir.path().join("b.fodt"));
write(&first, FODT);
write(&second, FODT);
let a = worker
.load(&format!("file://{}", first.display()))
.expect("a loads");
let b = worker
.load(&format!("file://{}", second.display()))
.expect("b loads");
worker.run(&a, "edit.select_all", &[]).expect("select all");
worker.cut(&a).expect("the cut runs");
let saved = |doc: &Doc, worker: &mut Worker, name: &str| {
let out = dir.path().join(name);
doc.save_as(worker, &format!("file://{}", out.display()), "fodt")
.expect("the save works");
std::fs::read_to_string(&out).expect("the save reads")
};
assert!(
!saved(&a, &mut worker, "a-out.fodt").contains("Hello"),
"a lost its text"
);
assert!(
saved(&b, &mut worker, "b-out.fodt").contains("Hello"),
"b kept its text"
);
worker.close_doc(b).expect("b closes");
worker.close_doc(a).expect("a closes");
}
#[test]
fn writer_reports_its_page_rects() {
let Some(install) = install() else {
eprintln!("SKIP: no pinned LOK build");
return;
};
let dir = tempfile::tempdir().expect("a temp dir");
let mut worker = Worker::spawn(&install, &dir.path().join("profile")).expect("the worker");
let source = dir.path().join("note.fodt");
write(&source, FODT);
let doc = worker
.load(&format!("file://{}", source.display()))
.expect("the document loads");
let pages = doc.pages(&mut worker).expect("the page rects");
let (width, height) = doc.size(&mut worker).expect("the size");
assert_eq!(pages.len(), 1, "one page: {pages:?}");
let (x, y, w, h) = pages[0];
assert!(
x > 0 && y > 0 && x + w < width as i32 && y + h <= height as i32,
"inside the document: {pages:?}"
);
}
/// A tile's mean channel value, 0 to 255.
fn brightness(tile: &Tile) -> u32 {
let sum: u64 = tile
.pixels
.chunks_exact(4)
.map(|px| u64::from(px[0]) + u64::from(px[1]) + u64::from(px[2]))
.sum();
(sum / (tile.pixels.len() as u64 / 4 * 3)) as u32
}
#[test]
fn the_page_follows_a_dark_theme_and_back() {
let Some(install) = install() else {
eprintln!("SKIP: no pinned LOK build");
return;
};
let dir = tempfile::tempdir().expect("a temp dir");
let mut worker = Worker::spawn(&install, &dir.path().join("profile")).expect("the worker");
for (name, text) in [("note.fodt", FODT), ("book.fods", super::input::FODS)] {
let source = dir.path().join(name);
write(&source, text);
let doc = worker
.load(&format!("file://{}", source.display()))
.expect("the document loads");
let area = (1440, 1440, 3840, 3840);
let seen = poll_until(&mut worker, &doc, &Events::default(), QUIET, |_| false);
let light = brightness(&doc.paint_tile(&mut worker, area, (64, 64)).expect("a tile"));
let paint = |worker: &mut Worker, scheme: &str, seen: &Events| {
worker.run(&doc, "view.theme", &[scheme]).expect("a theme");
let repainted = poll_until(worker, &doc, seen, SETTLE, |seen| seen.all);
assert!(repainted.all, "{name}: {scheme} repaints the view");
let tile = doc.paint_tile(worker, area, (64, 64)).expect("a tile");
(brightness(&tile), repainted)
};
let (dark, seen) = paint(&mut worker, "COLOR_SCHEME_ELY_DARK", &seen);
let (back, _) = paint(&mut worker, "COLOR_SCHEME_ELY_LIGHT", &seen);
eprintln!("THEME {name} light {light} dark {dark} back {back}");
assert!(
light > 200 && dark < 100 && back > 200,
"{name}: {light} {dark} {back}"
);
worker.close_doc(doc).expect("the document closes");
}
}
+128
View File
@@ -0,0 +1,128 @@
use super::*;
use crate::show;
/// Footer and number on; slide three hidden.
const FIELDS: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
<office:document xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0"
xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0"
xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0"
xmlns:draw="urn:oasis:names:tc:opendocument:xmlns:drawing:1.0"
xmlns:presentation="urn:oasis:names:tc:opendocument:xmlns:presentation:1.0"
xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0"
xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0"
office:version="1.3" office:mimetype="application/vnd.oasis.opendocument.presentation">
<office:automatic-styles>
<style:style style:name="dp1" style:family="drawing-page">
<style:drawing-page-properties presentation:display-footer="true" presentation:display-page-number="true" presentation:display-date-time="false"/>
</style:style>
<style:style style:name="dp2" style:family="drawing-page">
<style:drawing-page-properties presentation:visibility="hidden"/>
</style:style>
</office:automatic-styles>
<office:master-styles>
<style:master-page style:name="Plain">
<draw:frame presentation:class="footer" svg:x="1cm" svg:y="13.5cm" svg:width="10cm" svg:height="1.5cm"><draw:text-box><text:p><presentation:footer/></text:p></draw:text-box></draw:frame>
<draw:frame presentation:class="page-number" svg:x="20cm" svg:y="13.5cm" svg:width="6cm" svg:height="1.5cm"><draw:text-box><text:p><text:page-number>&lt;number&gt;</text:page-number></text:p></draw:text-box></draw:frame>
</style:master-page>
</office:master-styles>
<office:body><office:presentation>
<presentation:footer-decl presentation:name="ftr1">Ely footer</presentation:footer-decl>
<draw:page draw:name="One" draw:style-name="dp1" draw:master-page-name="Plain" presentation:use-footer-name="ftr1"/>
<draw:page draw:name="Two" draw:style-name="dp1" draw:master-page-name="Plain" presentation:use-footer-name="ftr1"/>
<draw:page draw:name="Three" draw:style-name="dp2" draw:master-page-name="Plain"/>
</office:presentation></office:body>
</office:document>"#;
fn open_fields() -> (tempfile::TempDir, Worker, Doc) {
let dir = tempfile::tempdir().expect("a temp dir");
let mut worker = Worker::spawn(&install().expect("LOK"), &dir.path().join("p")).expect("up");
let path = dir.path().join("fields.fodp");
std::fs::write(&path, FIELDS).expect("writes");
let doc = worker.load(&crate::file_url(&path)).expect("loads");
(dir, worker, doc)
}
#[test]
fn the_show_reads_every_slide_with_notes_and_hidden() {
if install().is_none() {
eprintln!("SKIP: no pinned LOK build");
return;
}
let (_dir, mut worker, doc) = open_fields();
let now = worker.now(&doc).expect("the state");
worker.run(&now, "notes.set", &["Say hello", "1"]).expect("sets");
let json = worker.values(&doc, show::SLIDE_SHOW).expect("reads");
let slides = show::slide_show(&json).expect("parses");
let seen: Vec<(bool, &str)> = slides.iter().map(|s| (s.hidden, s.notes.as_str())).collect();
assert_eq!(seen, [(false, ""), (false, "Say hello"), (true, "")]);
assert!(slides.iter().all(|s| s.effects.is_empty() && s.media.is_empty()));
assert_eq!(slides[0].transition.preset, "");
}
#[test]
fn field_text_lands_where_its_placeholder_is() {
if install().is_none() {
eprintln!("SKIP: no pinned LOK build");
return;
}
let (_dir, mut worker, doc) = open_fields();
// Rows with ink, not white, per layer.
let inked_rows = |part: usize, worker: &mut Worker| {
let layers = doc.slide_layers(worker, part, (480, 270)).expect("layers");
assert!(layers.list.iter().all(|l| l.part == show::Part::Still));
(layers.list.iter())
.map(|layer| {
(0..270)
.filter(|y| {
(0..480).any(|x| {
let at = (y * 480 + x) * 4;
layer.pixels[at + 3] > 0 && layer.pixels[at..at + 3] != [255, 255, 255]
})
})
.collect::<Vec<usize>>()
})
.collect::<Vec<_>>()
};
let shown = inked_rows(1, &mut worker);
assert_eq!(shown.len(), 3, "background, then footer and number");
assert!(
shown[1..].iter().all(|rows| !rows.is_empty() && rows.iter().all(|y| *y > 230)),
"both sit at the bottom: {shown:?}"
);
assert_eq!(inked_rows(2, &mut worker).len(), 1, "fields off, none drawn");
}
#[test]
fn a_pptx_keeps_notes_motion_and_media_editable() {
if install().is_none() {
eprintln!("SKIP: no pinned LOK build");
return;
}
let (dir, mut worker, doc) = super::show::open_deck();
let fade = r#"{"preset":"fade-smoothly","duration":1.5,"advance":"click","time":0,"all":false}"#;
worker.run(&doc, "slide.transition", &[fade]).expect("a transition");
worker.run(&doc, "draw.shape", &["rectangle", "true"]).expect("a shape");
let add = serde_json::json!({ "preset": "ooo-entrance-fly-in" }).to_string();
worker.run(&doc, "animation.add", &[&add]).expect("an effect");
let now = worker.now(&doc).expect("the state");
worker.run(&now, "notes.set", &["Speak slowly", "0"]).expect("notes");
let clip = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../tests/fixtures/media/clip.mp4");
let url = crate::file_url(&clip);
worker.run(&doc, "media.insert", &[&url, "false", "8000", "4500"]).expect("media");
let out = dir.path().join("kept.pptx");
doc.save_as(&mut worker, &crate::file_url(&out), "pptx").expect("saves");
let again = worker.load(&crate::file_url(&out)).expect("loads");
let slides = show::slide_show(&worker.values(&again, show::SLIDE_SHOW).expect("reads")).expect("parses");
let first = &slides[0];
assert_eq!((first.transition.preset.as_str(), first.notes.as_str()), ("fade-smoothly", "Speak slowly"));
assert_eq!(first.effects.iter().map(|e| e.preset.as_str()).collect::<Vec<_>>(), ["ooo-entrance-fly-in"]);
assert_eq!(first.media.len(), 1);
assert!(!first.media[0].missing, "{:?}", first.media[0]);
// Still editable: effect and notes change.
let change = serde_json::json!({ "index": 0, "duration": 3.0 }).to_string();
worker.run(&again, "animation.change", &[&change]).expect("changes");
let now = worker.now(&again).expect("the state");
worker.run(&now, "notes.set", &["Louder", "0"]).expect("edits notes");
let slides = show::slide_show(&worker.values(&again, show::SLIDE_SHOW).expect("reads")).expect("parses");
assert_eq!((slides[0].effects[0].duration, slides[0].notes.as_str()), (3.0, "Louder"));
}
+348
View File
@@ -0,0 +1,348 @@
use super::analysis::{cells, skip, steps};
use super::rules::row;
use super::sheet::open_book;
use super::*;
use crate::sheet::{
self, Scale,
csv::{Column, Encoding, Import},
};
/// Two sheets: S1 heads a sum, S2 holds a list.
fn book() -> String {
let first = [row(&["Name", "Total"]), row(&["Sum"])].concat();
let second: String = [["Item", "Note"], ["Pen", "a;b"], ["Ink", "2"]]
.iter()
.map(|cells| row(cells))
.collect();
format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<office:document xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0"
xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0"
xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0" office:version="1.3" office:mimetype="application/vnd.oasis.opendocument.spreadsheet">
<office:body><office:spreadsheet>
<table:table table:name="S1">{first}</table:table>
<table:table table:name="S2">{second}</table:table>
</office:spreadsheet></office:body>
</office:document>"#
)
}
fn import(dir: &Path, worker: &mut Worker, bytes: &[u8], how: &Import) -> Doc {
import_as("in.csv", dir, worker, bytes, how)
}
fn import_as(name: &str, dir: &Path, worker: &mut Worker, bytes: &[u8], how: &Import) -> Doc {
let path = dir.join(name);
std::fs::write(&path, bytes).expect("writes");
worker
.import(&crate::file_url(&path), how)
.expect("imports")
}
fn flat(worker: &mut Worker, doc: &Doc, dir: &Path) -> String {
let out = dir.join("out.fods");
doc.save_as(worker, &crate::file_url(&out), "fods")
.expect("saves");
std::fs::read_to_string(out).expect("reads")
}
#[test]
fn csv_imports_keep_codes_and_leave_formulas_as_text() {
if skip() {
return;
}
let dir = tempfile::tempdir().expect("a temp dir");
let mut worker = Worker::spawn(&install().expect("LOK"), &dir.path().join("p")).expect("up");
let text = "Account,Code,Amount,Formula,When\n00123,12345678901234567890,1.5,=1+1,2026-03-04\n";
let how = Import {
separator: ',',
quote: Some('"'),
encoding: Encoding::Utf8,
columns: vec![
Column::Text,
Column::Text,
Column::Standard,
Column::Standard,
Column::DateYmd,
],
};
let doc = import(dir.path(), &mut worker, text.as_bytes(), &how);
let xml = flat(&mut worker, &doc, dir.path());
assert!(xml.contains("<text:p>00123</text:p>"), "the zero stays");
assert!(
xml.contains("<text:p>12345678901234567890</text:p>"),
"the long code stays text"
);
assert!(
xml.contains(r#"office:value="1.5""#),
"the amount is a number"
);
assert!(!xml.contains("table:formula"), "no text ran as a formula");
assert!(
xml.contains("<text:p>=1+1</text:p>"),
"the formula text stays"
);
assert!(
xml.contains(r#"office:date-value="2026-03-04""#),
"the date column reads year first"
);
}
#[test]
fn csv_imports_take_the_encoding_separator_and_quote() {
if skip() {
return;
}
let dir = tempfile::tempdir().expect("a temp dir");
let mut worker = Worker::spawn(&install().expect("LOK"), &dir.path().join("p")).expect("up");
// 名称<TAB>值 / '苹果<TAB>x'<TAB>3 in GB18030.
let mut bytes = vec![0xC3, 0xFB, 0xB3, 0xC6, b'\t', 0xD6, 0xB5, b'\n', b'\''];
bytes.extend([0xC6, 0xBB, 0xB9, 0xFB]);
bytes.extend(b"\tx'\t3\n");
let how = Import {
separator: '\t',
quote: Some('\''),
encoding: Encoding::Gb18030,
columns: Vec::new(),
};
let doc = import(dir.path(), &mut worker, &bytes, &how);
let xml = flat(&mut worker, &doc, dir.path());
assert!(xml.contains("<text:p>名称</text:p>") && xml.contains("<text:p>值</text:p>"));
assert!(
xml.contains("<text:p>苹果<text:tab/>x</text:p>"),
"the quote holds the tab"
);
assert!(xml.contains(r#"office:value="3""#), "the number reads");
}
#[test]
fn a_txt_file_imports_as_a_sheet() {
if skip() {
return;
}
let dir = tempfile::tempdir().expect("a temp dir");
let mut worker = Worker::spawn(&install().expect("LOK"), &dir.path().join("p")).expect("up");
let how = Import {
separator: ',',
quote: Some('"'),
encoding: Encoding::Utf8,
columns: vec![Column::Text],
};
let doc = import_as(
"notes.txt",
dir.path(),
&mut worker,
b"Account,Value\n00123,1.5\n",
&how,
);
assert_eq!(
doc.sheets(&mut worker).expect("sheets").list.len(),
1,
"a sheet, not a page"
);
assert_eq!(cells(&mut worker, &doc, "A2"), "00123");
}
#[test]
fn text_exports_write_the_shown_sheet_as_values() {
if skip() {
return;
}
let (dir, mut worker, doc) = open_book(&book());
worker.run(&doc, "cell.goto", &["B2"]).expect("B2");
worker.run(&doc, "cell.enter", &["=1+2"]).expect("the sum");
let out = dir.path().join("out.csv");
doc.export_text(&mut worker, &crate::file_url(&out), ';')
.expect("exports S1");
let first = std::fs::read_to_string(&out).expect("reads");
assert_eq!(first, "Name;Total\nSum;3\n", "values, not formulas");
doc.show_sheet(&mut worker, 1).expect("S2");
doc.export_text(&mut worker, &crate::file_url(&out), ';')
.expect("exports S2");
let second = std::fs::read_to_string(&out).expect("reads");
assert_eq!(second, "Item;Note\nPen;\"a;b\"\nInk;2\n", "only S2");
}
fn layout(worker: &mut Worker, doc: &Doc) -> sheet::PrintLayout {
let json = worker.values(doc, sheet::LAYOUT).expect("the layout");
sheet::print_layout(&json).expect("parses")
}
#[test]
fn print_layouts_set_areas_titles_page_and_breaks() {
if skip() {
return;
}
let (dir, mut worker, doc) = open_book(&book());
let fresh = layout(&mut worker, &doc);
assert_eq!(fresh.areas, Vec::<String>::new());
assert_eq!(fresh.scale, Scale::Percent(100));
assert_eq!(fresh.sheets, ["S1", "S2"], "one page style");
let before = steps(&mut worker, &doc);
worker
.run(&doc, "print.area", &["$A$1:$B$2", "$1:$1", "$A:$A"])
.expect("sets the area");
let page = sheet::page_json(true, Scale::Fit(1, 0));
worker
.run(&doc, "page.setup", &[&page])
.expect("sets the page");
assert_eq!(steps(&mut worker, &doc), before + 2, "two undo steps");
let set = layout(&mut worker, &doc);
assert_eq!(set.areas, ["$A$1:$B$2"]);
assert_eq!((set.rows, set.columns), (Some((1, 1)), Some((1, 1))));
assert_eq!((set.landscape, set.scale), (true, Scale::Fit(1, 0)));
let wide = sheet::page_json(true, Scale::Percent(500));
assert!(
worker.run(&doc, "page.setup", &[&wide]).is_err(),
"500% is refused"
);
worker.run(&doc, "cell.goto", &["B3"]).expect("B3");
worker.run(&doc, "break.row", &[]).expect("a row break");
assert!(layout(&mut worker, &doc).row_break);
worker.run(&doc, "break.row_remove", &[]).expect("removed");
assert!(!layout(&mut worker, &doc).row_break);
let xlsx = dir.path().join("out.xlsx");
doc.save_as(&mut worker, &crate::file_url(&xlsx), "xlsx")
.expect("saves");
let again = worker.load(&crate::file_url(&xlsx)).expect("reloads");
let kept = layout(&mut worker, &again);
assert_eq!(kept.areas, ["$A$1:$B$2"], "the area survives XLSX");
assert_eq!((kept.rows, kept.columns), (Some((1, 1)), Some((1, 1))));
assert_eq!((kept.landscape, kept.scale), (true, Scale::Fit(1, 0)));
}
fn protection(worker: &mut Worker, doc: &Doc) -> sheet::Protection {
let json = worker
.values(doc, sheet::PROTECTION)
.expect("the protection");
sheet::protection(&json).expect("parses")
}
#[test]
fn protection_guards_locked_cells_and_keeps_its_password() {
if skip() {
return;
}
let (_dir, mut worker, doc) = open_book(&book());
worker.run(&doc, "cell.goto", &["B1"]).expect("B1");
worker
.run(&doc, "cells.lock", &["false"])
.expect("unlocks B1");
let allow = vec!["selectLocked".to_string(), "selectUnlocked".to_string()];
let on = sheet::protect_json("pw", &allow);
worker.run(&doc, "sheet.protect", &[&on]).expect("protects");
let read = protection(&mut worker, &doc);
assert!(read.protected && read.password);
assert_eq!(read.allow, allow);
worker.run(&doc, "cell.goto", &["A1"]).expect("A1");
let _ = worker.run(&doc, "cell.enter", &["x"]);
assert_eq!(cells(&mut worker, &doc, "A1"), "Name", "A1 is locked");
worker.run(&doc, "cell.goto", &["B1"]).expect("B1");
worker
.run(&doc, "cell.enter", &["y"])
.expect("B1 takes text");
assert_eq!(cells(&mut worker, &doc, "B1"), "y");
let wrong = sheet::unprotect_json("nope");
assert!(worker.run(&doc, "sheet.protect", &[&wrong]).is_err());
assert!(protection(&mut worker, &doc).protected, "still protected");
let right = sheet::unprotect_json("pw");
worker
.run(&doc, "sheet.protect", &[&right])
.expect("unprotects");
assert!(!protection(&mut worker, &doc).protected);
}
/// A cell's formula and value in an OOXML sheet.
fn ooxml_cell(column: &str, formula: &str, value: &str) -> String {
format!(r#"<c r="{column}1"><f>{formula}</f><v>{value}</v></c>"#)
}
#[test]
fn excel_caches_recompute_and_unknown_functions_show_errors() {
if skip() {
return;
}
let (dir, mut worker, doc) = open_book(&book());
let xlsx = dir.path().join("saved.xlsx");
doc.save_as(&mut worker, &crate::file_url(&xlsx), "xlsx")
.expect("saves");
let unpacked = dir.path().join("x");
let unzip = std::process::Command::new("unzip")
.args(["-q", "-o"])
.arg(&xlsx)
.arg("-d")
.arg(&unpacked)
.status()
.expect("unzip runs");
assert!(unzip.success());
// Excel caches: an unknown function's and a stale one.
let sheet = unpacked.join("xl/worksheets/sheet1.xml");
let xml = std::fs::read_to_string(&sheet).expect("the sheet");
let at = xml.find("<sheetData>").expect("sheet data") + "<sheetData>".len();
let both = ooxml_cell("A", "FUTUREFN(1)", "42") + &ooxml_cell("B", "1+1", "7");
let row = format!(r#"<row r="1">{both}</row>"#);
let end = xml[at..].find("</row>").expect("row one") + at + "</row>".len();
std::fs::write(&sheet, format!("{}{row}{}", &xml[..at], &xml[end..])).expect("writes");
let excel = dir.path().join("excel.xlsx");
let zip = std::process::Command::new("zip")
.current_dir(&unpacked)
.args(["-q", "-r"])
.arg(&excel)
.arg(".")
.status()
.expect("zip runs");
assert!(zip.success());
let opened = worker.load(&crate::file_url(&excel)).expect("loads");
assert_eq!(
cells(&mut worker, &opened, "A1"),
"#NAME?",
"no fake result"
);
assert_eq!(
cells(&mut worker, &opened, "B1"),
"2",
"a stale cache recomputes"
);
let again = dir.path().join("again.fods");
opened
.save_as(&mut worker, &crate::file_url(&again), "fods")
.expect("saves");
let kept = std::fs::read_to_string(again).expect("reads");
assert!(kept.contains("of:=futurefn(1)"), "the formula stays");
}
#[test]
fn linked_values_stay_as_saved_and_are_named() {
if skip() {
return;
}
let (dir, mut worker, doc) = open_book(&book());
let source = dir.path().join("source.ods");
doc.save_as(&mut worker, &crate::file_url(&source), "ods")
.expect("saves the source");
let reader = worker
.load(&crate::file_url(&dir.path().join("book.fods")))
.expect("a second copy");
let formula = format!("='{}/[source.ods]S1'!A1", dir.path().display());
worker.run(&reader, "cell.goto", &["D1"]).expect("D1");
worker
.run(&reader, "cell.enter", &[&formula])
.expect("the link");
assert_eq!(cells(&mut worker, &reader, "D1"), "Name");
let saved = dir.path().join("reader.xlsx");
reader
.save_as(&mut worker, &crate::file_url(&saved), "xlsx")
.expect("saves");
std::fs::remove_file(&source).expect("the source goes");
let offline = worker.load(&crate::file_url(&saved)).expect("reloads");
assert_eq!(
cells(&mut worker, &offline, "D1"),
"Name",
"the old value stays"
);
let links =
sheet::links(&worker.values(&offline, sheet::LINKS).expect("reads")).expect("parses");
assert_eq!(links, [crate::file_url(&source)], "the source is named");
let plain = sheet::links(&worker.values(&doc, sheet::LINKS).expect("reads")).expect("parses");
assert!(plain.is_empty());
}
+281
View File
@@ -0,0 +1,281 @@
use super::input::{open, saved};
use super::*;
use crate::review;
fn run(worker: &mut Worker, doc: &Doc, id: &str, values: &[&str]) {
worker
.run(doc, id, values)
.unwrap_or_else(|error| panic!("{id}: {error}"));
}
fn values(worker: &mut Worker, doc: &Doc, command: &str) -> String {
worker
.values(doc, command)
.unwrap_or_else(|error| panic!("{command}: {error}"))
}
fn type_in(worker: &mut Worker, doc: &Doc, text: &str) {
for key in text.chars() {
worker.post_key(doc, key as u32, 0).expect("a key");
}
}
/// Reads until `done`: the kernel lists comments after layout.
fn awaited<T>(mut read: impl FnMut() -> T, done: impl Fn(&T) -> bool) -> T {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
loop {
let value = read();
if done(&value) || std::time::Instant::now() > deadline {
return value;
}
std::thread::sleep(std::time::Duration::from_millis(100));
}
}
#[test]
fn references_land() {
if install().is_none() {
eprintln!("SKIP: no pinned LOK build");
return;
}
let (dir, mut worker, doc) = open(FODT);
click(&mut worker, &doc, 150, 150);
run(
&mut worker,
&doc,
"format.style",
&["Heading 1", "ParagraphStyles"],
);
run(&mut worker, &doc, "bookmark.insert", &["Start"]);
let marks = review::bookmarks(&values(&mut worker, &doc, ".uno:Bookmarks"))
.expect("the bookmarks parse");
assert_eq!(marks.len(), 1);
assert_eq!(marks[0].name, "Start");
assert!(marks[0].rect.is_some(), "the mark has a place");
run(
&mut worker,
&doc,
"reference.insert",
&["13", "2", "Start", "2"],
);
run(
&mut worker,
&doc,
"link.insert",
&["Ely", "https://example.org/"],
);
run(&mut worker, &doc, "note.footnote", &[]);
type_in(&mut worker, &doc, "Foot");
click(&mut worker, &doc, 150, 150);
run(&mut worker, &doc, "note.endnote", &[]);
click(&mut worker, &doc, 150, 150);
run(&mut worker, &doc, "index.insert", &["Contents"]);
run(&mut worker, &doc, "index.update", &[]);
let text = saved(&dir, &mut worker, &doc, "refs.fodt");
for mark in [
"<text:table-of-content ",
">Contents<",
"<text:note text:id=\"ftn",
"text:note-class=\"endnote\"",
"<text:bookmark text:name=\"Start\"",
"<text:bookmark-ref ",
"xlink:href=\"https://example.org/\"",
] {
assert!(text.contains(mark), "{mark} lands");
}
}
#[test]
fn formulas_insert_edit_and_take_captions() {
if install().is_none() {
eprintln!("SKIP: no pinned LOK build");
return;
}
let (dir, mut worker, doc) = open(FODT);
click(&mut worker, &doc, 150, 150);
let formula = |worker: &mut Worker| {
review::formula(&values(worker, &doc, ".uno:Formula")).expect("the formula parses")
};
assert_eq!(formula(&mut worker), None, "no formula yet");
run(&mut worker, &doc, "formula.insert", &["a over b"]);
assert_eq!(formula(&mut worker).as_deref(), Some("a over b"));
run(&mut worker, &doc, "formula.insert", &["c over d"]);
assert_eq!(formula(&mut worker).as_deref(), Some("c over d"));
run(&mut worker, &doc, "caption.insert", &["Figure", "ratio"]);
let text = saved(&dir, &mut worker, &doc, "formula.fodt");
assert_eq!(text.matches("<draw:object").count(), 1, "the edit replaces");
assert!(text.contains("c over d"), "the edit lands");
let caption = super::input::paragraphs(&text)
.into_iter()
.find(|line| line.contains("Figure"));
assert!(
caption
.as_deref()
.is_some_and(|line| line.ends_with("Figure 1: ratio")),
"the caption numbers: {caption:?}"
);
}
#[test]
fn formula_edits_keep_the_text_and_the_place() {
if install().is_none() {
eprintln!("SKIP: no pinned LOK build");
return;
}
let (dir, mut worker, doc) = open(FODT);
click(&mut worker, &doc, 150, 150);
type_in(&mut worker, &doc, "PREFIX");
let wide = "\u{1d465} + e\u{301}";
run(&mut worker, &doc, "formula.insert", &[wide]);
let formula = |worker: &mut Worker| {
review::formula(&values(worker, &doc, ".uno:Formula")).expect("the formula parses")
};
assert_eq!(formula(&mut worker).as_deref(), Some(wide));
run(&mut worker, &doc, "image.anchor_page", &[]);
let placed = spot(&saved(&dir, &mut worker, &doc, "placed.fodt"));
assert!(placed.is_some(), "the formula sits on the page");
run(&mut worker, &doc, "formula.insert", &["c over d"]);
assert_eq!(formula(&mut worker).as_deref(), Some("c over d"));
let text = saved(&dir, &mut worker, &doc, "edited.fodt");
assert!(text.contains("PREFIX"), "the text before stays");
assert_eq!(spot(&text), placed, "the page position stays");
assert!(
text.contains("text:anchor-type=\"page\""),
"the place stays"
);
run(&mut worker, &doc, "edit.undo", &[]);
let text = saved(&dir, &mut worker, &doc, "undone.fodt");
assert!(text.contains(wide), "one undo restores the formula");
assert!(text.contains("text:anchor-type=\"page\""), "and its place");
}
/// The page-anchored frame's svg:x and svg:y.
fn spot(text: &str) -> Option<(String, String)> {
let frame = text.split("<draw:frame ").find(|frame| {
frame
.split('>')
.next()
.is_some_and(|head| head.contains("text:anchor-type=\"page\""))
})?;
let head = frame.split('>').next()?;
let value = |name: &str| {
let rest = head.split(&format!("{name}=\"")).nth(1)?;
rest.split('"').next().map(str::to_string)
};
Some((value("svg:x")?, value("svg:y")?))
}
/// An inline formula with its own spacing.
const INLINE: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
<office:document xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:draw="urn:oasis:names:tc:opendocument:xmlns:drawing:1.0" xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0" xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0" office:version="1.3" office:mimetype="application/vnd.oasis.opendocument.text">
<office:automatic-styles><style:style style:name="fr1" style:family="graphic"><style:graphic-properties fo:margin-left="0.25in" fo:margin-right="0.5in" style:vertical-pos="top" style:vertical-rel="line"/></style:style></office:automatic-styles>
<office:body><office:text><text:p>Lead <draw:frame draw:style-name="fr1" draw:name="Formula1" text:anchor-type="as-char" svg:width="0.5in" svg:height="0.4in"><draw:object><math xmlns="http://www.w3.org/1998/Math/MathML" display="block"><semantics><mfrac><mi>a</mi><mi>b</mi></mfrac><annotation encoding="StarMath 5.0">a over b</annotation></semantics></math></draw:object></draw:frame> tail</text:p></office:text></office:body>
</office:document>"#;
#[test]
fn an_inline_formula_keeps_its_spacing_and_alignment() {
if install().is_none() {
eprintln!("SKIP: no pinned LOK build");
return;
}
let (dir, mut worker, doc) = open(INLINE);
let mut seen = Events::default();
for x in (1500..4000).step_by(100) {
click(&mut worker, &doc, x, 1600);
seen = poll_until(
&mut worker,
&doc,
&seen,
std::time::Duration::from_millis(400),
|seen| seen.graphic.is_some(),
);
if seen.graphic.is_some() {
break;
}
}
assert!(seen.graphic.is_some(), "a click selects the formula");
let formula = |worker: &mut Worker| {
review::formula(&values(worker, &doc, ".uno:Formula")).expect("the formula parses")
};
assert_eq!(formula(&mut worker).as_deref(), Some("a over b"));
run(&mut worker, &doc, "formula.insert", &["c over d"]);
assert_eq!(formula(&mut worker).as_deref(), Some("c over d"));
let text = saved(&dir, &mut worker, &doc, "inline.fodt");
for mark in [
"fo:margin-left=\"0.25in\"",
"fo:margin-right=\"0.5in\"",
"style:vertical-pos=\"top\"",
"style:vertical-rel=\"line\"",
"text:anchor-type=\"as-char\"",
] {
assert!(text.contains(mark), "{mark} stays");
}
let line = super::input::paragraphs(&text).join("");
assert!(
line.starts_with("Lead ") && line.ends_with(" tail"),
"{line:?}"
);
run(&mut worker, &doc, "edit.undo", &[]);
let text = saved(&dir, &mut worker, &doc, "undone.fodt");
assert!(text.contains("a over b"), "one undo restores the formula");
}
#[test]
fn comments_and_changes_review() {
if install().is_none() {
eprintln!("SKIP: no pinned LOK build");
return;
}
let (dir, mut worker, doc) = open(FODT);
worker.set_author("Ada Lovelace").expect("the author");
click(&mut worker, &doc, 150, 150);
run(&mut worker, &doc, "comment.insert", &["First note"]);
let notes = |worker: &mut Worker| {
review::notes(&values(worker, &doc, ".uno:ViewAnnotations")).expect("the notes parse")
};
let first = awaited(|| notes(&mut worker), |notes| !notes.is_empty());
assert_eq!(first.len(), 1);
assert_eq!(first[0].text, "First note");
assert_eq!(first[0].author, "Ada Lovelace", "the set name signs");
assert!(first[0].anchor.0 > 0, "the note has an anchor");
let id = first[0].id.clone();
run(&mut worker, &doc, "comment.reply", &[&id, "An answer"]);
let thread = awaited(|| notes(&mut worker), |notes| notes.len() == 2);
assert_eq!(thread.len(), 2);
assert_eq!(thread[1].parent, id);
run(&mut worker, &doc, "comment.insert", &["a\n\nNext"]);
let both = awaited(|| notes(&mut worker), |notes| notes.len() == 3);
assert_eq!(both[2].text, "a\n\nNext", "empty paragraphs count once");
run(&mut worker, &doc, "comment.insert", &["a\n\n"]);
let all = awaited(|| notes(&mut worker), |notes| notes.len() == 4);
assert_eq!(all[3].text, "a\n\n", "trailing empty paragraphs stay");
click(&mut worker, &doc, 150, 150);
run(&mut worker, &doc, "track.toggle", &[]);
let changes = |worker: &mut Worker| {
review::changes(&values(worker, &doc, ".uno:AcceptTrackedChanges"))
.expect("the changes parse")
};
type_in(&mut worker, &doc, "Kept ");
let kept = awaited(|| changes(&mut worker), |list| !list.is_empty());
assert_eq!(kept.len(), 1, "typing is tracked: {kept:?}");
assert!(kept[0].anchor.is_some(), "the change has a place");
run(
&mut worker,
&doc,
"track.accept",
&[&kept[0].index.to_string()],
);
let left = awaited(|| changes(&mut worker), Vec::is_empty);
assert!(left.is_empty(), "the change is taken");
type_in(&mut worker, &doc, "Dropped ");
let dropped = awaited(|| changes(&mut worker), |list| !list.is_empty());
assert_eq!(dropped.len(), 1);
run(&mut worker, &doc, "track.reject_all", &[]);
let left = awaited(|| changes(&mut worker), Vec::is_empty);
assert!(left.is_empty(), "the change is dropped");
let text = saved(&dir, &mut worker, &doc, "review.fodt");
for mark in ["First note", "An answer", "<office:annotation ", "Kept "] {
assert!(text.contains(mark), "{mark} lands");
}
assert!(!text.contains("Dropped"), "the rejected words go");
}
@@ -0,0 +1,108 @@
//! QA-10: edit, save as DOCX, reopen, compare.
use super::input::{open, paragraphs};
use super::*;
/// Round-trips `doc` through DOCX to flat XML.
fn round(dir: &tempfile::TempDir, worker: &mut Worker, doc: &Doc, name: &str) -> String {
let docx = dir.path().join(format!("{name}.docx"));
doc.save_as(worker, &crate::file_url(&docx), "docx")
.expect("the DOCX saves");
let back = worker
.load(&crate::file_url(&docx))
.expect("the DOCX reopens");
let flat = dir.path().join(format!("{name}.fodt"));
back.save_as(worker, &crate::file_url(&flat), "fodt")
.expect("flattens");
worker.close_doc(back).expect("closes");
std::fs::read_to_string(&flat).expect("reads")
}
fn run(worker: &mut Worker, doc: &Doc, id: &str, values: &[&str]) {
worker
.run(doc, id, values)
.unwrap_or_else(|error| panic!("{id}: {error}"));
}
pub(super) fn type_in(worker: &mut Worker, doc: &Doc, text: &str) {
for key in text.chars() {
let code = if key == '\n' { 1280 } else { 0 };
let char_code = if key == '\n' { 13 } else { key as u32 };
worker.post_key(doc, char_code, code).expect("a key");
}
}
#[test]
fn a_contract_keeps_numbering_comments_and_changes() {
if install().is_none() {
eprintln!("SKIP: no pinned LOK build");
return;
}
let (dir, mut worker, doc) = open(FODT);
click(&mut worker, &doc, 150, 150);
run(&mut worker, &doc, "list.numbering", &[]);
type_in(&mut worker, &doc, "Term one\nTerm two\n");
run(&mut worker, &doc, "comment.insert", &["Check the term"]);
run(&mut worker, &doc, "track.toggle", &[]);
type_in(&mut worker, &doc, "Added clause");
let text = round(&dir, &mut worker, &doc, "contract");
for mark in [
"<text:list",
"Term one",
"Term two",
"Check the term",
"<text:changed-region",
"Added clause",
] {
assert!(text.contains(mark), "{mark} survives DOCX");
}
}
#[test]
fn a_long_report_keeps_headings_contents_and_a_split_table() {
if install().is_none() {
eprintln!("SKIP: no pinned LOK build");
return;
}
let (dir, mut worker, doc) = open(FODT);
click(&mut worker, &doc, 150, 150);
run(
&mut worker,
&doc,
"format.style",
&["Heading 1", "ParagraphStyles"],
);
type_in(&mut worker, &doc, " report\n");
run(&mut worker, &doc, "table.insert", &["2", "80"]);
run(&mut worker, &doc, "table.repeat_heading", &[]);
click(&mut worker, &doc, 150, 150);
run(&mut worker, &doc, "index.insert", &["Contents"]);
let pages = doc.pages(&mut worker).expect("pages");
assert!(
pages.len() >= 3,
"the table runs over pages: {}",
pages.len()
);
let text = round(&dir, &mut worker, &doc, "report");
for mark in [
"<text:table-of-content",
"text:outline-level=\"1\"",
"<table:table-header-rows>",
] {
assert!(text.contains(mark), "{mark} survives DOCX");
}
assert_eq!(
text.matches("<table:table-row").count(),
80,
"every row survives"
);
let heading = paragraphs(
&text
.replace("<text:h", "<text:p")
.replace("</text:h>", "</text:p>"),
);
assert!(
heading.iter().any(|line| line == "Report"),
"the heading text survives"
);
}
+487
View File
@@ -0,0 +1,487 @@
use super::sheet::open_book;
use super::*;
use crate::sheet;
const SETTLE: std::time::Duration = std::time::Duration::from_secs(3);
pub(super) fn row(cells: &[&str]) -> String {
let cells: String = cells
.iter()
.map(|cell| match cell.parse::<f64>() {
Ok(_) => format!(
r#"<table:table-cell office:value-type="float" office:value="{cell}"><text:p>{cell}</text:p></table:table-cell>"#
),
Err(_) => format!(r#"<table:table-cell><text:p>{cell}</text:p></table:table-cell>"#),
})
.collect();
format!("<table:table-row>{cells}</table:table-row>")
}
/// Name, Qty, City; one repeated row.
fn data() -> String {
let rows: String = [
["Name", "Qty", "City"],
["a", "3", "x"],
["b", "1", "y"],
["a", "3", "x"],
["c", "2", "y"],
]
.iter()
.map(|cells| row(cells))
.collect();
format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<office:document xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0"
xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0"
xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0" office:version="1.3" office:mimetype="application/vnd.oasis.opendocument.spreadsheet">
<office:body><office:spreadsheet><table:table table:name="S">{rows}</table:table></office:spreadsheet></office:body>
</office:document>"#
)
}
fn open() -> (tempfile::TempDir, Worker, Doc, Events) {
let (dir, mut worker, doc) = open_book(&data());
let seen = poll_until(&mut worker, &doc, &Events::default(), SETTLE, |seen| {
seen.cell.cursor.is_some()
});
(dir, worker, doc, seen)
}
/// The cells of `range`, as the kernel copies them.
fn cells(worker: &mut Worker, doc: &Doc, range: &str) -> String {
worker.run(doc, "cell.goto", &[range]).expect("the range");
doc.selection_text(worker).expect("the text")
}
/// The latest report of `name`.
fn state<'a>(seen: &'a Events, name: &str) -> Option<&'a str> {
seen.states
.iter()
.rfind(|(state, _)| state == name)
.map(|(_, value)| value.as_str())
}
fn skip() -> bool {
let missing = install().is_none();
if missing {
eprintln!("SKIP: no pinned LOK build");
}
missing
}
#[test]
fn number_formats_alignment_and_fills_apply() {
if skip() {
return;
}
let (_dir, mut worker, doc, seen) = open();
worker.run(&doc, "cell.goto", &["B2:B5"]).expect("B2:B5");
let read = worker.values(&doc, sheet::NUMBER).expect("the format");
let kind = sheet::number_format(&read).expect("parses").1;
assert_eq!(kind, "general", "the standard format is general");
worker
.run(&doc, "number.code", &["0.00%"])
.expect("a format code");
let read = worker.values(&doc, sheet::NUMBER).expect("the format");
assert_eq!(
sheet::number_format(&read).expect("parses"),
("0.00%".to_string(), "percent".to_string())
);
worker.run(&doc, "number.currency", &[]).expect("currency");
let read = worker.values(&doc, sheet::NUMBER).expect("the format");
assert_eq!(sheet::number_format(&read).expect("parses").1, "currency");
let check = |worker: &mut Worker, code: &str| {
let json = worker
.values(&doc, &sheet::number_check_query(code))
.expect("the check");
sheet::number_valid(&json).expect("parses")
};
assert!(check(&mut worker, "#,##0.00 \"kg\""));
assert!(!check(&mut worker, "[ZZZ]0"), "an unknown keyword fails");
assert!(
worker.run(&doc, "number.code", &["[ZZZ]0"]).is_err(),
"the kernel refuses a broken code"
);
worker.run(&doc, "align.center", &[]).expect("center");
worker.run(&doc, "cells.wrap", &["true"]).expect("wrap");
let states = poll_until(&mut worker, &doc, &seen, SETTLE, |seen| {
state(seen, ".uno:AlignHorizontalCenter") == Some("true")
&& state(seen, ".uno:WrapText") == Some("true")
});
assert_eq!(state(&states, ".uno:WrapText"), Some("true"));
// The cell properties of the document's own cell formats.
let saved = |worker: &mut Worker| {
let out = _dir.path().join("borders.fods");
doc.save_as(worker, &format!("file://{}", out.display()), "fods")
.expect("the save");
let xml = std::fs::read_to_string(&out).expect("the saved book");
let start = xml.find("<office:automatic-styles>").expect("cell formats");
xml[start..]
.split("<style:table-cell-properties")
.skip(1)
.map(|cell| cell.split("/>").next().expect("a closed element"))
.collect::<String>()
};
worker.run(&doc, "cells.borders_all", &[]).expect("all");
let all = saved(&mut worker);
assert!(
all.contains("solid") && !all.contains("fo:border-top=\"none\""),
"every line"
);
worker.run(&doc, "cells.borders_outer", &[]).expect("outer");
let outer = saved(&mut worker);
assert!(
outer.contains("solid") && outer.contains("fo:border-top=\"none\""),
"no inner lines"
);
worker.run(&doc, "cells.borders_none", &[]).expect("none");
assert!(!saved(&mut worker).contains("solid"), "no lines");
worker.run(&doc, "cells.fill", &["16711680"]).expect("red");
assert!(saved(&mut worker).contains("fo:background-color=\"#ff0000\""));
worker.run(&doc, "cells.fill", &["-1"]).expect("no fill");
assert!(!saved(&mut worker).contains("#ff0000"), "the fill clears");
}
#[test]
fn sorting_filtering_and_duplicates() {
if skip() {
return;
}
let (_dir, mut worker, doc, _seen) = open();
worker.run(&doc, "cell.goto", &["A1"]).expect("A1");
let sort = ["true", "2", "true", "0", "true", "0", "true"];
worker.run(&doc, "data.sort", &sort).expect("by Qty");
assert_eq!(cells(&mut worker, &doc, "A2:A5").trim(), "b\nc\na\na");
worker.run(&doc, "cell.goto", &["A1"]).expect("A1");
let query = r#"{"conditions":[{"field":2,"op":"equals","value":"x"}]}"#;
worker
.run(&doc, "data.filter", &[query])
.expect("City is x");
let dropped = |worker: &mut Worker, column: u32| -> Vec<String> {
let query = sheet::cell_query(sheet::FILTER, column, 0);
let json = worker.values(&doc, &query).expect("the entries");
sheet::filter_values(&json)
.expect("parses")
.entries
.into_iter()
.filter(|entry| !entry.selected)
.map(|entry| entry.text)
.collect()
};
assert_eq!(dropped(&mut worker, 2), ["y"]);
let picked = r#"{"column":0,"conditions":[{"op":"values","values":["c"]}]}"#;
worker
.run(&doc, "data.filter", &[picked])
.expect("Name is c");
assert_eq!(dropped(&mut worker, 0), ["a", "b"], "the column keeps c");
assert_eq!(dropped(&mut worker, 2), ["y"], "the City filter stays");
worker.run(&doc, "data.unfilter", &[]).expect("no filter");
assert!(dropped(&mut worker, 2).is_empty());
let bad = r#"{"conditions":[{"field":9,"op":"equals","value":"x"}]}"#;
assert!(
worker.run(&doc, "data.filter", &[bad]).is_err(),
"out of the data"
);
worker.run(&doc, "cell.goto", &["B3"]).expect("B3");
worker.run(&doc, "select.data", &[]).expect("the data area");
let json = worker.values(&doc, sheet::SELECTED).expect("the selection");
let picked = sheet::selected_range(&json)
.expect("parses")
.expect("one range");
assert_eq!(
(picked.top.as_slice(), picked.header),
(&["Name".to_string(), "Qty".into(), "City".into()][..], true)
);
let (c1, r1, c2, r2) = picked.area.expect("data");
assert_eq!((c1, r1, c2, r2), (0, 0, 2, 4), "{}", picked.range);
let corners = [c1, r1, c2, r2].map(|n| n.to_string());
let mut values = vec!["true", "true", "true"];
values.extend(corners.iter().map(String::as_str));
values.push("0");
let now = worker.now(&doc).expect("the state");
worker.run(&now, "data.dedupe", &values).expect("removes");
assert_eq!(cells(&mut worker, &doc, "A2:A5").trim(), "b\nc\na");
}
#[test]
fn a_filter_button_and_alt_down_ask_for_ely_menus() {
if skip() {
return;
}
let (_dir, mut worker, doc, seen) = open();
worker.run(&doc, "cell.goto", &["A1"]).expect("A1");
worker.run(&doc, "data.autofilter", &[]).expect("buttons");
let a1 = seen.cell.cursor.expect("the cursor");
click(&mut worker, &doc, a1.0 + a1.2 - 60, a1.1 + a1.3 / 2);
let asked = poll_until(&mut worker, &doc, &seen, SETTLE, |seen| {
seen.cell.request.is_some()
});
assert_eq!(
asked.cell.request,
Some(Request::Filter { column: 0, row: 0 })
);
worker.run(&doc, "cell.goto", &["E2"]).expect("E2");
let rule = crate::sheet::Validation {
kind: "list".into(),
operator: "equal".into(),
list: vec!["Red".into(), "Green \"G\"".into()],
dropdown: true,
ignore_blank: true,
show_input: true,
input_title: "Pick".into(),
input_message: "A color".into(),
show_error: true,
error_style: "stop".into(),
error_title: "Bad".into(),
error_message: "Red or green".into(),
..Default::default()
};
worker
.run(&doc, "rules.validation", &[&rule.to_json()])
.expect("a list");
let json = worker
.values(&doc, &sheet::cell_query(sheet::LIST, 4, 1))
.expect("the choices");
assert_eq!(
sheet::list_values(&json).expect("parses"),
["Red", "Green \"G\""]
);
let read = sheet::validation(&worker.values(&doc, sheet::VALIDATION).expect("the rule"))
.expect("parses");
assert_eq!(
(
read.kind.as_str(),
read.dropdown,
read.input_message.as_str()
),
("list", true, "A color")
);
worker.post_key(&doc, 0, 1024 | 0x4000).expect("Alt+Down");
let listed = poll_until(&mut worker, &doc, &asked, SETTLE, |seen| {
seen.cell.requests > asked.cell.requests
});
assert_eq!(
listed.cell.request,
Some(Request::Choices { column: 4, row: 1 })
);
for c in "Blue".chars() {
worker.post_key(&doc, c as u32, 0).expect("a key");
}
worker.post_key(&doc, 13, 1280).expect("Enter");
let refused = poll_until(&mut worker, &doc, &listed, SETTLE, |seen| {
seen.cell.requests > listed.cell.requests
});
assert!(
matches!(
&refused.cell.request,
Some(Request::Invalid { accepted: false, title, .. }) if title == "Bad"
),
"{:?}",
refused.cell.request
);
worker.run(&doc, "cell.goto", &["E3"]).expect("E3");
let away = poll_until(&mut worker, &doc, &refused, SETTLE, |seen| {
seen.cell.address == "E3"
});
worker.run(&doc, "cell.goto", &["E2"]).expect("E2");
let kept = poll_until(&mut worker, &doc, &away, SETTLE, |seen| {
seen.cell.address == "E2"
});
assert_eq!(kept.cell.formula, "", "a stop keeps it out");
}
#[test]
fn any_value_keeps_its_input_help() {
if skip() {
return;
}
let (_dir, mut worker, doc, _seen) = open();
worker.run(&doc, "cell.goto", &["D2"]).expect("D2");
let rule = crate::sheet::Validation {
kind: "any".into(),
operator: "between".into(),
show_input: true,
input_message: "An id".into(),
error_style: "stop".into(),
..Default::default()
};
worker
.run(&doc, "rules.validation", &[&rule.to_json()])
.expect("help alone");
let read = sheet::validation(&worker.values(&doc, sheet::VALIDATION).expect("the rule"))
.expect("parses");
assert_eq!(
(read.show_input, read.input_message.as_str()),
(true, "An id")
);
let none = crate::sheet::Validation {
kind: "any".into(),
operator: "between".into(),
error_style: "stop".into(),
..Default::default()
};
worker
.run(&doc, "rules.validation", &[&none.to_json()])
.expect("cleared");
let read = sheet::validation(&worker.values(&doc, sheet::VALIDATION).expect("the rule"))
.expect("parses");
assert!(!read.show_input, "the help is gone");
}
#[test]
fn whole_numbers_text_columns_and_value_pastes() {
if skip() {
return;
}
let (_dir, mut worker, doc, seen) = open();
worker.run(&doc, "cell.goto", &["B2:B5"]).expect("B2:B5");
let rule = crate::sheet::Validation {
kind: "whole".into(),
operator: "between".into(),
value1: "1".into(),
value2: "10".into(),
ignore_blank: true,
show_error: true,
error_style: "info".into(),
..Default::default()
};
worker
.run(&doc, "rules.validation", &[&rule.to_json()])
.expect("whole numbers");
worker.run(&doc, "cell.goto", &["B2"]).expect("B2");
for c in "50".chars() {
worker.post_key(&doc, c as u32, 0).expect("a key");
}
worker.post_key(&doc, 13, 1280).expect("Enter");
let warned = poll_until(&mut worker, &doc, &seen, SETTLE, |seen| {
seen.cell.request.is_some()
});
assert!(
matches!(
warned.cell.request,
Some(Request::Invalid { accepted: true, .. })
),
"{:?}",
warned.cell.request
);
assert_eq!(
cells(&mut worker, &doc, "B2").trim(),
"50",
"a note lets it in"
);
worker.run(&doc, "cell.goto", &["A7"]).expect("A7");
worker.run(&doc, "cell.enter", &["p;q;r"]).expect("text");
worker.run(&doc, "cell.goto", &["A7"]).expect("A7");
worker
.run(&doc, "data.columns", &["59,34,76,1"])
.expect("split");
assert_eq!(cells(&mut worker, &doc, "A7:C7").trim(), "p\tq\tr");
worker.run(&doc, "cell.goto", &["E2"]).expect("E2");
worker
.run(&doc, "cell.enter", &["=B3*2"])
.expect("a formula");
worker.run(&doc, "cell.goto", &["E2"]).expect("E2");
worker.copy(&doc).expect("the copy");
worker.run(&doc, "cell.goto", &["F2"]).expect("F2");
worker.run(&doc, "paste.values", &[]).expect("values");
worker.run(&doc, "cell.goto", &["G2"]).expect("G2");
worker.run(&doc, "paste.formulas", &[]).expect("formulas");
worker.run(&doc, "cell.goto", &["F2"]).expect("F2");
let pasted = poll_until(&mut worker, &doc, &warned, SETTLE, |seen| {
seen.cell.address == "F2" && !seen.cell.formula.is_empty()
});
assert_eq!(pasted.cell.formula, "2", "the value alone");
worker.run(&doc, "cell.goto", &["G2"]).expect("G2");
let formula = poll_until(&mut worker, &doc, &pasted, SETTLE, |seen| {
seen.cell.address == "G2"
});
assert_eq!(formula.cell.formula, "=D3*2", "the reference moves along");
}
#[test]
fn conditional_formats_add_list_and_go() {
if skip() {
return;
}
let (_dir, mut worker, doc, _seen) = open();
worker.run(&doc, "cell.goto", &["B2:B5"]).expect("B2:B5");
let read = |worker: &mut Worker| {
sheet::formats(&worker.values(&doc, sheet::FORMATS).expect("the formats")).expect("parses")
};
for rule in [
r#"{"kind":"cell","operator":"greater","value1":"2","style":"Good"}"#,
r##"{"kind":"scale","colors":["#F8696B","#FFEB84","#63BE7B"]}"##,
r##"{"kind":"databar","color":"#638EC6"}"##,
] {
worker.run(&doc, "rules.conditional", &[rule]).expect(rule);
}
let formats = read(&mut worker);
let kinds: Vec<&str> = formats.iter().map(|format| format.kind.as_str()).collect();
assert_eq!(kinds, ["cell", "scale", "databar"]);
assert_eq!(
(
formats[0].style.as_str(),
formats[0].value.as_str(),
formats[0].range.as_str()
),
("Good", "2", "B2:B5")
);
let unknown = r#"{"kind":"cell","operator":"greater","value1":"2","style":"Nope"}"#;
assert!(worker.run(&doc, "rules.conditional", &[unknown]).is_err());
let remove = format!(r#"{{"remove":{}}}"#, formats[1].key);
worker
.run(&doc, "rules.conditional", &[&remove])
.expect("removes");
assert_eq!(read(&mut worker).len(), 2);
}
#[test]
fn search_counts_and_replaces_cells() {
if skip() {
return;
}
let (_dir, mut worker, doc, seen) = open();
let find = ["a", "false", "1", "256", "0", "1"];
worker.run(&doc, "find.run", &find).expect("find all");
let all = poll_until(&mut worker, &doc, &seen, SETTLE, |seen| seen.searched > 0);
assert_eq!(all.found.len(), 3, "one per cell: {:?}", all.found);
let step = ["a", "false", "0", "256", "0", "1"];
worker.run(&doc, "find.run", &step).expect("find next");
let next = poll_until(&mut worker, &doc, &all, SETTLE, |seen| {
seen.searched > all.searched
});
let at = next.found.first().expect("a match");
assert!(all.found.contains(at), "{at:?} is one of {:?}", all.found);
let replace = ["a", "false", "3", "256", "0", "1", "z"];
worker
.run(&doc, "find.replace", &replace)
.expect("replace all");
assert_eq!(cells(&mut worker, &doc, "A1:A5").trim(), "Nzme\nz\nb\nz\nc");
}
#[test]
fn enter_and_tab_move_the_cell_cursor() {
if skip() {
return;
}
let (_dir, mut worker, doc, seen) = open();
worker.run(&doc, "cell.goto", &["F1"]).expect("F1");
let at = poll_until(&mut worker, &doc, &seen, SETTLE, |seen| {
seen.cell.address == "F1"
});
worker.post_text(&doc, true, "7").expect("typed text");
worker.post_key(&doc, 0, 1280).expect("Enter");
let down = poll_until(&mut worker, &doc, &at, SETTLE, |seen| {
seen.cell.address == "F2"
});
assert_eq!(down.cell.address, "F2", "Enter moves down");
worker.post_key(&doc, '8' as u32, 0).expect("a key");
worker.post_key(&doc, 0, 1282).expect("Tab");
let right = poll_until(&mut worker, &doc, &down, SETTLE, |seen| {
seen.cell.address == "G2"
});
assert_eq!(right.cell.address, "G2", "Tab moves right");
}
+182
View File
@@ -0,0 +1,182 @@
use super::input::FODS;
use super::*;
/// How long a command may take to report.
const SETTLE: std::time::Duration = std::time::Duration::from_secs(3);
#[test]
fn a11y_follows_calc_cells() {
if install().is_none() {
eprintln!("SKIP: no pinned LOK build");
return;
}
let (_dir, mut worker, doc) = open_sheet();
let base = poll_until(&mut worker, &doc, &Events::default(), SETTLE, |_| false);
worker.post_key(&doc, 0, 1027).expect("Right to B1");
let b1 = poll_until(&mut worker, &doc, &base, SETTLE, |_| false);
worker.post_key(&doc, 0, 1026).expect("Left to A1");
let a1 = poll_until(&mut worker, &doc, &b1, SETTLE, |seen| {
seen.a11y.text == "Hello 世界"
});
assert_eq!(
a1.a11y.text, "Hello 世界",
"the cell text bridges without editing"
);
worker.post_key(&doc, 0, 1027).expect("Right to B1 again");
let empty = poll_until(&mut worker, &doc, &a1, SETTLE, |seen| {
seen.a11y.text.is_empty()
});
assert_eq!(empty.a11y.text, "", "the empty B1 clears the bridge");
}
/// The FODS fixture in a fresh worker.
fn open_sheet() -> (tempfile::TempDir, Worker, Doc) {
let install = install().expect("the pinned LOK build");
let dir = tempfile::tempdir().expect("a temp dir");
let mut worker = Worker::spawn(&install, &dir.path().join("profile")).expect("the worker");
let source = dir.path().join("in.fods");
write(&source, FODS);
let doc = worker
.load(&format!("file://{}", source.display()))
.expect("the sheet loads");
(dir, worker, doc)
}
#[test]
fn a_cancelled_cell_edit_restores_the_cell_text() {
if install().is_none() {
eprintln!("SKIP: no pinned LOK build");
return;
}
let (_dir, mut worker, doc) = open_sheet();
let base = poll_until(&mut worker, &doc, &Events::default(), SETTLE, |_| false);
worker.post_key(&doc, 0, 769).expect("F2 edits A1");
worker
.post_key(&doc, 'Z' as u32, 537 | 0x1000)
.expect("Z types");
let typed = poll_until(&mut worker, &doc, &base, SETTLE, |seen| {
seen.a11y.text.ends_with('Z')
});
assert_eq!(typed.a11y.text, "Hello 世界Z", "the edit bridges");
worker.post_key(&doc, 0, 1281).expect("Escape cancels");
let back = poll_until(&mut worker, &doc, &typed, SETTLE, |seen| {
seen.a11y.text == "Hello 世界"
});
assert_eq!(back.a11y.text, "Hello 世界", "the cancel restores the cell");
}
#[test]
fn undo_and_redo_work_inside_a_cell_edit() {
if install().is_none() {
eprintln!("SKIP: no pinned LOK build");
return;
}
for with_history in [false, true] {
let (_dir, mut worker, doc) = open_sheet();
let base = poll_until(&mut worker, &doc, &Events::default(), SETTLE, |_| false);
if with_history {
worker
.run(&doc, "format.bold", &[])
.expect("a document step");
}
worker.post_key(&doc, 0, 769).expect("F2 edits A1");
worker
.post_key(&doc, 'Z' as u32, 537 | 0x1000)
.expect("Z types");
let typed = poll_until(&mut worker, &doc, &base, SETTLE, |seen| {
seen.a11y.text == "Hello 世界Z"
});
assert_eq!(typed.a11y.text, "Hello 世界Z");
worker
.run(&doc, "edit.undo", &[])
.expect("undo in the edit");
let undone = poll_until(&mut worker, &doc, &typed, SETTLE, |seen| {
seen.a11y.text == "Hello 世界"
});
assert_eq!(undone.a11y.text, "Hello 世界", "history {with_history}");
worker
.run(&doc, "edit.redo", &[])
.expect("redo in the edit");
let redone = poll_until(&mut worker, &doc, &undone, SETTLE, |seen| {
seen.a11y.text == "Hello 世界Z"
});
assert_eq!(redone.a11y.text, "Hello 世界Z", "history {with_history}");
}
}
/// Three sheets; T hides, U refers to S.
pub(super) const SHEETS: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
<office:document xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0" office:version="1.3" office:mimetype="application/vnd.oasis.opendocument.spreadsheet">
<office:automatic-styles><style:style style:name="ta2" style:family="table"><style:table-properties table:display="false"/></style:style></office:automatic-styles>
<office:body><office:spreadsheet>
<table:table table:name="S"><table:table-row><table:table-cell><text:p>Hello 世界</text:p></table:table-cell></table:table-row></table:table>
<table:table table:name="T" table:style-name="ta2"><table:table-row><table:table-cell/></table:table-row></table:table>
<table:table table:name="U"><table:table-row><table:table-cell table:formula="of:=[S.A1]"/><table:table-cell table:formula="of:=INDIRECT(&quot;S.A1&quot;)"/></table:table-row></table:table>
</office:spreadsheet></office:body>
</office:document>"#;
#[test]
fn sheets_list_switch_and_the_cell_reports() {
if install().is_none() {
eprintln!("SKIP: no pinned LOK build");
return;
}
let (_dir, mut worker, doc) = open_book(SHEETS);
let sheets = doc.sheets(&mut worker).expect("the sheets");
let names: Vec<_> = sheets
.list
.iter()
.map(|sheet| (sheet.name.as_str(), sheet.visible))
.collect();
assert_eq!(names, [("S", true), ("T", false), ("U", true)]);
assert_eq!(sheets.current, 0);
let first = poll_until(&mut worker, &doc, &Events::default(), SETTLE, |seen| {
seen.cell.address == "A1" && seen.cell.formula == "Hello 世界" && seen.cell.cursor.is_some()
});
assert!(first.cell.cursor.is_some(), "the cell cursor shows on load");
let geometry = crate::sheet::geometry(
&worker
.values(&doc, crate::sheet::GEOMETRY)
.expect("the geometry"),
)
.expect("the geometry parses");
worker.post_key(&doc, 0, 1027).expect("Right to B1");
worker.post_key(&doc, 0, 1024).expect("Down to B2");
worker.post_key(&doc, 0, 1024).expect("Down to B3");
let b3 = poll_until(&mut worker, &doc, &first, SETTLE, |seen| {
seen.cell.address == "B3"
});
let (x, y, _, _) = b3.cell.cursor.expect("the cell cursor");
assert_eq!(
(x, y),
(geometry.columns.start(1), geometry.rows.start(2)),
"the geometry places B3 where the kernel does"
);
let first = b3;
doc.show_sheet(&mut worker, 2).expect("U shows");
assert_eq!(doc.sheets(&mut worker).expect("the sheets").current, 2);
let third = poll_until(&mut worker, &doc, &first, SETTLE, |seen| {
seen.cell.formula.starts_with('=')
});
assert!(
third.cell.formula.contains("S!A1"),
"U's formula shows: {}",
third.cell.formula
);
assert!(third.cell.sheets > first.cell.sheets, "the switch reports");
}
/// `text` loaded as a spreadsheet the view shows.
pub(super) fn open_book(text: &str) -> (tempfile::TempDir, Worker, Doc) {
let install = install().expect("the pinned LOK build");
let dir = tempfile::tempdir().expect("a temp dir");
let mut worker = Worker::spawn(&install, &dir.path().join("profile")).expect("the worker");
let source = dir.path().join("book.fods");
write(&source, text);
let doc = worker
.load(&format!("file://{}", source.display()))
.expect("the book loads");
doc.visible_area(&mut worker, (0, 0, 15000, 10000))
.expect("the area");
(dir, worker, doc)
}
+322
View File
@@ -0,0 +1,322 @@
use super::analysis::steps;
use super::*;
use crate::show;
/// Two slides on the default master.
pub(crate) const DECK: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
<office:document xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0"
xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0"
xmlns:draw="urn:oasis:names:tc:opendocument:xmlns:drawing:1.0"
xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0"
office:version="1.3" office:mimetype="application/vnd.oasis.opendocument.presentation">
<office:body><office:presentation>
<draw:page draw:name="One"><draw:frame svg:x="2cm" svg:y="2cm" svg:width="10cm" svg:height="3cm"><draw:text-box><text:p>First</text:p></draw:text-box></draw:frame></draw:page>
<draw:page draw:name="Two"><draw:frame svg:x="2cm" svg:y="2cm" svg:width="10cm" svg:height="3cm"><draw:text-box><text:p>Second</text:p></draw:text-box></draw:frame></draw:page>
</office:presentation></office:body>
</office:document>"#;
pub(super) fn open_deck() -> (tempfile::TempDir, Worker, Doc) {
let dir = tempfile::tempdir().expect("a temp dir");
let mut worker = Worker::spawn(&install().expect("LOK"), &dir.path().join("p")).expect("up");
let path = dir.path().join("deck.fodp");
std::fs::write(&path, DECK).expect("writes");
let doc = worker.load(&crate::file_url(&path)).expect("loads");
(dir, worker, doc)
}
fn slides(worker: &mut Worker, doc: &Doc) -> Vec<(String, bool)> {
let sheets = doc.sheets(worker).expect("the slides");
sheets
.list
.into_iter()
.map(|slide| (slide.name, slide.visible))
.collect()
}
fn design(worker: &mut Worker, doc: &Doc) -> show::Design {
show::design(&worker.values(doc, show::DESIGN).expect("reads")).expect("parses")
}
/// Runs `id`; the kernel's history gains one step.
fn one_step(worker: &mut Worker, doc: &Doc, id: &str, values: &[&str]) {
let before = steps(worker, doc);
worker.run(doc, id, values).expect(id);
assert_eq!(steps(worker, doc), before + 1, "{id} is one undo step");
}
#[test]
fn slides_insert_copy_hide_move_and_delete() {
if install().is_none() {
eprintln!("SKIP: no pinned LOK build");
return;
}
let (_dir, mut worker, doc) = open_deck();
one_step(&mut worker, &doc, "slide.insert", &[]);
one_step(&mut worker, &doc, "slide.duplicate", &[]);
assert_eq!(slides(&mut worker, &doc).len(), 4);
one_step(&mut worker, &doc, "slide.hide", &[]);
assert!(!slides(&mut worker, &doc)[2].1, "the copy hides");
one_step(&mut worker, &doc, "slide.show", &[]);
one_step(&mut worker, &doc, "slide.last", &[]);
assert_eq!(slides(&mut worker, &doc)[2].0, "Two", "Two moved up one");
one_step(&mut worker, &doc, "slide.first", &[]);
assert_eq!(doc.sheets(&mut worker).expect("slides").current, 0);
one_step(&mut worker, &doc, "slide.delete", &[]);
assert_eq!(slides(&mut worker, &doc).len(), 3);
}
#[test]
fn size_layout_master_and_theme_change_in_one_step() {
if install().is_none() {
eprintln!("SKIP: no pinned LOK build");
return;
}
let (_dir, mut worker, doc) = open_deck();
one_step(&mut worker, &doc, "slide.size", &["25400", "19050", "true"]);
let (w, h) = doc.size(&mut worker).expect("the size");
assert!(
(w - 14400).abs() <= 2 && (h - 10800).abs() <= 2,
"4:3 in twips: {w} x {h}"
);
one_step(&mut worker, &doc, "slide.layout", &["1"]);
assert_eq!(design(&mut worker, &doc).layout, 1);
worker
.run(&doc, "master.edit", &[])
.expect("the master view");
worker.run(&doc, "master.insert", &[]).expect("a master");
worker.run(&doc, "master.close", &[]).expect("back");
let read = design(&mut worker, &doc);
assert_eq!(read.masters, ["Default", "Default 1"]);
assert_eq!(read.master, "Default");
one_step(&mut worker, &doc, "slide.master", &["Default 1"]);
assert_eq!(design(&mut worker, &doc).master, "Default 1");
assert!(
worker.run(&doc, "slide.master", &["Nope"]).is_err(),
"no such master"
);
let theme = read.themes.first().expect("a color set").clone();
one_step(&mut worker, &doc, "slide.theme", &[&theme]);
assert_eq!(design(&mut worker, &doc).theme, theme);
assert!(
worker.run(&doc, "slide.theme", &["Nope"]).is_err(),
"no such set"
);
}
#[test]
fn a_master_edit_shows_on_its_slides() {
if install().is_none() {
eprintln!("SKIP: no pinned LOK build");
return;
}
let (dir, mut worker, doc) = open_deck();
let side = (160, 90);
let before = doc
.paint_part(&mut worker, (1, false), side)
.expect("paints");
assert_eq!(before.len(), 160 * 90 * 4);
worker
.run(&doc, "master.edit", &[])
.expect("the master view");
assert_eq!(slides(&mut worker, &doc).len(), 1, "the parts are masters");
let master = doc
.paint_part(&mut worker, (0, true), side)
.expect("paints");
assert_ne!(master, before, "a master paints as a master");
worker
.paste(&doc, "text/plain;charset=utf-8", b"Brand")
.expect("pastes onto the master");
worker
.run(&doc, "master.close", &[])
.expect("back to slides");
assert_eq!(slides(&mut worker, &doc).len(), 2, "slides again");
let after = doc
.paint_part(&mut worker, (1, false), side)
.expect("paints");
assert_ne!(before, after, "slide two shows the master's change");
let out = dir.path().join("out.fodp");
doc.save_as(&mut worker, &crate::file_url(&out), "fodp")
.expect("saves");
let xml = std::fs::read_to_string(out).expect("reads");
let master = &xml[xml.find("<style:master-page").expect("a master")..];
assert!(master.contains("Brand"), "the master holds the edit");
}
#[test]
fn tiles_keep_their_picture_during_text_edit() {
if install().is_none() {
eprintln!("SKIP: no pinned LOK build");
return;
}
let (_dir, mut worker, doc) = open_deck();
let far = (8000, 6000, 3000, 2000);
let before = doc.paint_tile(&mut worker, far, (64, 64)).unwrap().pixels;
click(&mut worker, &doc, 2500, 1500);
for c in "Hey".chars() {
worker.post_key(&doc, c as u32, 0).unwrap();
}
let during = doc.paint_tile(&mut worker, far, (64, 64)).unwrap().pixels;
assert!(before == during, "a tile away from the edit changed");
}
fn objects(worker: &mut Worker, doc: &Doc) -> show::Objects {
show::objects(&worker.values(doc, show::OBJECTS).expect("reads")).expect("parses")
}
#[test]
fn objects_read_select_move_and_guide() {
if install().is_none() {
eprintln!("SKIP: no pinned LOK build");
return;
}
let (_dir, mut worker, doc) = open_deck();
worker
.run(&doc, "draw.shape", &["rectangle", "true"])
.expect("draws");
let read = objects(&mut worker, &doc);
assert_eq!(read.objects.len(), 2);
assert_eq!(read.objects[1].kind, "shape");
assert!(read.objects[1].selected && !read.objects[0].selected);
let state = worker.now(&doc).expect("the state");
worker
.run(&state, "object.select", &["0", "false"])
.expect("selects");
let read = objects(&mut worker, &doc);
assert_eq!(read.selection.expect("one").kind, "text");
let state = worker.now(&doc).expect("the state");
worker
.run(&state, "object.select", &["1", "true"])
.expect("adds");
let selection = objects(&mut worker, &doc).selection.expect("two");
assert!(selection.count == 2 && selection.can_group);
worker
.run(&doc, "object.deselect", &["false"])
.expect("deselects");
assert!(
objects(&mut worker, &doc).selection.is_none(),
"Add alone picks none"
);
// Transform arguments are twips: 1701 twips is 3 cm.
let state = worker.now(&doc).expect("the state");
worker
.run(&state, "object.select", &["1", "false"])
.expect("selects");
worker.run(&doc, "object.x", &["1701"]).expect("moves");
let rect = objects(&mut worker, &doc).selection.expect("one").rect;
assert!((rect.0 - 3000).abs() <= 1, "{rect:?}");
worker
.run(&doc, "guide.add", &["0", "0", "4000"])
.expect("adds");
let state = worker.now(&doc).expect("the state");
worker
.run(&state, "guide.move", &["0", "0", "5000"])
.expect("moves");
let guides = objects(&mut worker, &doc).guides;
assert_eq!(guides.len(), 1);
assert_eq!(
(guides[0].kind, guides[0].at.1),
(show::GuideKind::Horizontal, 5000)
);
let state = worker.now(&doc).expect("the state");
worker
.run(&state, "guide.delete", &["0", "true"])
.expect("deletes");
assert!(objects(&mut worker, &doc).guides.is_empty());
}
#[test]
fn pictures_land_mid_slide_and_replace_in_place() {
if install().is_none() {
eprintln!("SKIP: no pinned LOK build");
return;
}
let (dir, mut worker, doc) = open_deck();
let red = super::layout::png(dir.path(), "red.png", [255, 0, 0, 255]);
let blue = super::layout::png(dir.path(), "blue.png", [0, 0, 255, 255]);
worker.run(&doc, "image.insert", &[&red]).expect("inserts");
let first = objects(&mut worker, &doc).selection.expect("the picture");
let (w, h) = doc.size(&mut worker).expect("the slide");
let mid = |(x, y, width, height): (i64, i64, i64, i64)| (x + width / 2, y + height / 2);
let (cx, cy) = mid(first.rect);
let (sw, sh) = (w * 127 / 72, h * 127 / 72);
assert!(
(cx - sw / 2).abs() < 20 && (cy - sh / 2).abs() < 20,
"{first:?}"
);
worker
.run(&doc, "image.replace", &[&blue])
.expect("replaces");
let read = objects(&mut worker, &doc);
assert_eq!(read.objects.len(), 2, "replaced, not added");
assert_eq!(mid(read.selection.expect("the picture").rect), (cx, cy));
}
/// A sideways-growing box and a small 20 pt box.
const FITS: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
<office:document xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0"
xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0"
xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0"
xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0"
xmlns:draw="urn:oasis:names:tc:opendocument:xmlns:drawing:1.0"
xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0"
office:version="1.3" office:mimetype="application/vnd.oasis.opendocument.presentation">
<office:automatic-styles>
<style:style style:name="wide" style:family="graphic"><style:graphic-properties draw:auto-grow-width="true" draw:auto-grow-height="false"/></style:style>
<style:style style:name="small" style:family="graphic"><style:graphic-properties draw:auto-grow-width="false" draw:auto-grow-height="false"/><style:text-properties fo:font-size="20pt"/></style:style>
</office:automatic-styles>
<office:body><office:presentation>
<draw:page draw:name="One">
<draw:frame draw:style-name="wide" svg:x="1cm" svg:y="1cm" svg:width="10cm" svg:height="1cm"><draw:text-box><text:p>Grows</text:p></draw:text-box></draw:frame>
<draw:frame draw:style-name="small" svg:x="1cm" svg:y="5cm" svg:width="2cm" svg:height="0.5cm"><draw:text-box><text:p>A long sentence that does not fit in this small box.</text:p></draw:text-box></draw:frame>
</draw:page>
</office:presentation></office:body>
</office:document>"#;
#[test]
fn fit_rules_set_both_growths_and_undo_keeps_the_text() {
if install().is_none() {
eprintln!("SKIP: no pinned LOK build");
return;
}
let dir = tempfile::tempdir().expect("a temp dir");
let mut worker = Worker::spawn(&install().expect("LOK"), &dir.path().join("p")).expect("up");
let path = dir.path().join("fits.fodp");
std::fs::write(&path, FITS).expect("writes");
let doc = worker.load(&crate::file_url(&path)).expect("loads");
let saved = |worker: &mut Worker, name: &str| {
let out = dir.path().join(name);
doc.save_as(worker, &crate::file_url(&out), "fodp")
.expect("saves");
std::fs::read_to_string(out).expect("reads")
};
let state = worker.now(&doc).expect("the state");
worker
.run(&state, "object.select", &["0", "false"])
.expect("selects");
worker.run(&doc, "text.fit", &["off"]).expect("fits");
let xml = saved(&mut worker, "off.fodp");
assert!(
!xml.contains(r#"draw:auto-grow-width="true""#),
"off stops sideways growth"
);
let state = worker.now(&doc).expect("the state");
worker
.run(&state, "object.select", &["1", "false"])
.expect("selects");
let before = doc
.paint_part(&mut worker, (0, false), (320, 180))
.expect("paints");
let body = |xml: &str| xml[xml.find("A long sentence").expect("the text") - 80..].to_string();
let body_before = body(&saved(&mut worker, "before.fodp"));
worker.run(&doc, "text.fit", &["shrink"]).expect("shrinks");
worker.run(&doc, "edit.undo", &[]).expect("undoes");
let after = doc
.paint_part(&mut worker, (0, false), (320, 180))
.expect("paints");
assert!(before == after, "undo brings the picture back");
assert_eq!(
body(&saved(&mut worker, "after.fodp")),
body_before,
"and the paragraph"
);
}
+362
View File
@@ -0,0 +1,362 @@
use super::analysis::steps;
use super::show::open_deck;
use super::*;
use crate::show::{self, ChartData};
fn chart(worker: &mut Worker, doc: &Doc) -> Option<show::SlideChart> {
show::slide_chart(&worker.values(doc, crate::sheet::CHART).expect("reads")).expect("parses")
}
fn data(series: &[(&str, [f64; 2])]) -> ChartData {
ChartData {
categories: vec!["Q1".into(), "Q2".into()],
series: series
.iter()
.map(|(name, values)| (name.to_string(), values.iter().map(|v| Some(*v)).collect()))
.collect(),
}
}
#[test]
fn charts_insert_change_and_undo_with_their_data() {
if install().is_none() {
eprintln!("SKIP: no pinned LOK build");
return;
}
let (_dir, mut worker, doc) = open_deck();
let look = crate::sheet::Look {
kind: "column".into(),
title: "Sales".into(),
legend: true,
..Default::default()
};
let first = data(&[("North", [1.0, 2.0]), ("South", [3.0, 4.0])]);
worker
.run(
&doc,
"show.chart",
&[&show::chart_json(&look, Some(&first))],
)
.expect("inserts");
let read = chart(&mut worker, &doc).expect("the new chart is selected");
assert_eq!(
(read.look.kind.as_str(), read.look.title.as_str()),
("column", "Sales")
);
assert_eq!(read.data.as_ref(), Some(&first));
let pie = crate::sheet::Look {
kind: "pie".into(),
..look
};
let second = data(&[("West", [5.0, 6.0])]);
worker
.run(
&doc,
"show.chart",
&[&show::chart_json(&pie, Some(&second))],
)
.expect("changes");
let read = chart(&mut worker, &doc).expect("still selected");
assert_eq!(
(read.look.kind.as_str(), read.data.as_ref()),
("pie", Some(&second))
);
let objects =
show::objects(&worker.values(&doc, show::OBJECTS).expect("reads")).expect("parses");
assert_eq!(objects.objects.len(), 2, "changed in place, not added");
worker.run(&doc, "edit.undo", &[]).expect("undoes");
let state = worker.now(&doc).expect("the state");
worker
.run(&state, "object.select", &["1", "false"])
.expect("selects");
assert_eq!(
chart(&mut worker, &doc).expect("the chart").data,
Some(first)
);
// A ragged table is refused and nothing changes.
let bad = r#"{"type":"column","title":"","legend":true,"xtitle":"","ytitle":"","data":{"categories":["A"],"series":[{"name":"S","values":[1,2]}]}}"#;
let before = steps(&mut worker, &doc);
assert!(worker.run(&doc, "show.chart", &[bad]).is_err());
assert_eq!(steps(&mut worker, &doc), before);
}
#[test]
fn notes_set_read_and_undo_as_one_step() {
if install().is_none() {
eprintln!("SKIP: no pinned LOK build");
return;
}
let (_dir, mut worker, doc) = open_deck();
let notes = |worker: &mut Worker| {
show::notes(&worker.values(&doc, show::NOTES).expect("reads")).expect("parses")
};
let blank = notes(&mut worker);
assert!(
blank.editable && blank.text.is_empty() && !blank.rich,
"{blank:?}"
);
let before = steps(&mut worker, &doc);
notes_set(&mut worker, &doc, &["Say hello\nThen the plan", "0"])
.expect("sets");
let set = notes(&mut worker);
assert_eq!(
(set.text.as_str(), set.rich),
("Say hello\nThen the plan", false)
);
assert_eq!(steps(&mut worker, &doc), before + 1);
notes_set(&mut worker, &doc, &["", "0"]).expect("clears");
assert_eq!(notes(&mut worker).text, "");
worker.run(&doc, "edit.undo", &[]).expect("undoes");
assert_eq!(notes(&mut worker).text, "Say hello\nThen the plan");
// Notes go to the slide named, not the one shown.
notes_set(&mut worker, &doc, &["For two", "1"])
.expect("sets");
assert_eq!(notes(&mut worker).part, Some(0));
assert_eq!(notes(&mut worker).text, "Say hello\nThen the plan");
doc.show_sheet(&mut worker, 1).expect("the second slide");
let second = notes(&mut worker);
assert_eq!((second.part, second.text.as_str()), (Some(1), "For two"));
}
/// Sets notes as an app does, at a fresh read.
fn notes_set(worker: &mut Worker, doc: &Doc, values: &[&str]) -> io::Result<()> {
let at = worker.now(doc)?;
worker.run(&at, "notes.set", values)
}
#[test]
fn notes_for_a_slide_number_need_the_state_it_was_read_in() {
if install().is_none() {
eprintln!("SKIP: no pinned LOK build");
return;
}
let (_dir, mut worker, doc) = open_deck();
doc.show_sheet(&mut worker, 0).expect("the first slide");
let read = worker.now(&doc).expect("the state");
worker.run(&doc, "slide.delete", &[]).expect("deletes the first");
// Slide 0 was the deleted one when read.
let stale = worker.run(&read, "notes.set", &["Late", "0"]).expect_err("an older state");
assert!(crate::is_stale(&stale), "{stale}");
let kept = show::notes(&worker.values(&doc, show::NOTES).expect("reads")).expect("parses");
assert_eq!(kept.text, "", "the next slide keeps its notes");
}
#[test]
fn comments_carry_their_slide_author_edits_and_deletes() {
if install().is_none() {
eprintln!("SKIP: no pinned LOK build");
return;
}
let (_dir, mut worker, doc) = open_deck();
let remarks = |worker: &mut Worker| {
show::remarks(&worker.values(&doc, ".uno:ViewAnnotations").expect("reads")).expect("parses")
};
let count = |worker: &mut Worker| {
let read = worker.values(&doc, show::OBJECTS).expect("reads");
show::objects(&read).expect("parses").objects.len()
};
worker.set_author("Ada Lovelace").expect("the author");
doc.show_sheet(&mut worker, 1).expect("the second slide");
let shapes = count(&mut worker);
worker
.run(&doc, "comment.insert", &["Check the numbers"])
.expect("inserts");
let list = remarks(&mut worker);
assert_eq!(list.len(), 1);
assert_eq!(count(&mut worker), shapes, "comments are not objects");
assert_eq!(
(list[0].part, list[0].text.as_str(), list[0].author.as_str()),
(1, "Check the numbers", "Ada Lovelace")
);
let id = list[0].id.clone();
worker
.run(&doc, "remark.edit", &[&id, "Check the totals"])
.expect("edits");
assert_eq!(remarks(&mut worker)[0].text, "Check the totals");
let ids: Vec<String> = remarks(&mut worker).into_iter().map(|r| r.id).collect();
for id in ids {
worker.run(&doc, "remark.delete", &[&id]).expect("deletes");
}
assert!(remarks(&mut worker).is_empty());
}
#[test]
fn tables_insert_and_grow_on_a_slide() {
if install().is_none() {
eprintln!("SKIP: no pinned LOK build");
return;
}
let (_dir, mut worker, doc) = open_deck();
worker
.run(&doc, "table.insert", &["3", "2"])
.expect("inserts");
let read = show::objects(&worker.values(&doc, show::OBJECTS).expect("reads")).expect("parses");
let table = read.selection.expect("the new table is selected");
assert_eq!(table.kind, "table");
let (w, h) = doc.size(&mut worker).expect("the slide");
let (sw, sh) = (w * 127 / 72, h * 127 / 72);
let (x, y, tw, th) = table.rect;
// Centred across; rows grow down from the middle.
assert!((x + tw / 2 - sw / 2).abs() < 50, "{table:?}");
assert!(y > 0 && y + th < sh, "on the slide: {table:?}");
let tall = table.rect.3;
for id in ["table.row_below", "table.column_right"] {
let before = steps(&mut worker, &doc);
worker.run(&doc, id, &[]).expect(id);
assert_eq!(steps(&mut worker, &doc), before + 1, "{id} is one step");
}
let now = show::objects(&worker.values(&doc, show::OBJECTS).expect("reads")).expect("parses");
let table = now
.objects
.iter()
.find(|o| o.kind == "table")
.expect("the table");
assert!(table.rect.3 > tall, "a row more: {table:?}");
}
#[test]
fn notes_keep_one_look_and_refuse_varied_ones() {
if install().is_none() {
eprintln!("SKIP: no pinned LOK build");
return;
}
let (dir, mut worker, doc) = open_deck();
notes_set(&mut worker, &doc, &["Say this first", "0"])
.expect("sets");
let out = dir.path().join("plain.fodp");
doc.save_as(&mut worker, &crate::file_url(&out), "fodp")
.expect("saves");
for kind in ["odp", "pptx"] {
let copy = dir.path().join(format!("plain.{kind}"));
doc.save_as(&mut worker, &crate::file_url(&copy), kind)
.expect("saves");
}
for name in ["plain.fodp", "plain.odp", "plain.pptx"] {
let doc = worker
.load(&crate::file_url(&dir.path().join(name)))
.expect("loads");
let read = show::notes(&worker.values(&doc, show::NOTES).expect("reads")).expect("parses");
assert_eq!(
(read.text.as_str(), read.rich),
("Say this first", false),
"{name}"
);
}
// PowerPoint styles every run; new text keeps the look.
let deck = worker
.load(&crate::file_url(&dir.path().join("plain.pptx")))
.expect("loads");
worker
.run(&deck, "notes.set", &["Then the plan", "0"])
.expect("sets");
let again = dir.path().join("again.fodp");
deck.save_as(&mut worker, &crate::file_url(&again), "fodp")
.expect("saves");
let xml = std::fs::read_to_string(&again).expect("reads");
let span = xml
.split("Then the plan")
.next()
.expect("text")
.rsplit("<text:span text:style-name=\"")
.next();
let style = span
.and_then(|s| s.split('"').next())
.expect("a styled span");
let def = xml
.split(&format!("<style:style style:name=\"{style}\""))
.nth(1)
.expect("its style");
let def = def.split("</style:style>").next().expect("ends");
assert!(
def.contains(r#"fo:font-size="20pt""#) && def.contains("Arial"),
"{def}"
);
// Make one word bold, as another editor would.
let bold = r#"<style:style style:name="Bold" style:family="text"><style:text-properties fo:font-weight="bold"/></style:style>"#;
let xml = std::fs::read_to_string(&out)
.expect("reads")
.replacen(
"<office:automatic-styles>",
&format!("<office:automatic-styles>{bold}"),
1,
)
.replacen(
"<text:p>Say this first</text:p>",
r#"<text:p>Say <text:span text:style-name="Bold">this</text:span> first</text:p>"#,
1,
);
let rich = dir.path().join("rich.fodp");
std::fs::write(&rich, xml).expect("writes");
let doc = worker.load(&crate::file_url(&rich)).expect("loads");
let read = show::notes(&worker.values(&doc, show::NOTES).expect("reads")).expect("parses");
assert_eq!((read.text.as_str(), read.rich), ("Say this first", true));
notes_set(&mut worker, &doc, &["Plain", "0"])
.expect_err("refuses");
let kept = show::notes(&worker.values(&doc, show::NOTES).expect("reads")).expect("parses");
assert_eq!(
kept.text, "Say this first",
"varied formatting is not overwritten"
);
}
#[test]
fn notes_keep_one_paragraph_look_and_refuse_two() {
if install().is_none() {
eprintln!("SKIP: no pinned LOK build");
return;
}
let (dir, mut worker, doc) = open_deck();
notes_set(&mut worker, &doc, &["One\nTwo", "0"])
.expect("sets");
let out = dir.path().join("two.fodp");
doc.save_as(&mut worker, &crate::file_url(&out), "fodp")
.expect("saves");
let end = r#"<style:style style:name="End" style:family="paragraph"><style:paragraph-properties fo:text-align="end"/></style:style>"#;
let styled = |one: &str, two: &str| {
std::fs::read_to_string(&out)
.expect("reads")
.replacen(
"<office:automatic-styles>",
&format!("<office:automatic-styles>{end}"),
1,
)
.replacen("<text:p>One</text:p>", one, 1)
.replacen("<text:p>Two</text:p>", two, 1)
};
let both = r#"<text:p text:style-name="End">One</text:p>"#;
let both = styled(both, &both.replace("One", "Two"));
let path = dir.path().join("both.fodp");
std::fs::write(&path, both).expect("writes");
let deck = worker.load(&crate::file_url(&path)).expect("loads");
let read = show::notes(&worker.values(&deck, show::NOTES).expect("reads")).expect("parses");
assert!(!read.rich, "one look throughout");
worker
.run(&deck, "notes.set", &["Three", "0"])
.expect("sets");
let again = dir.path().join("again.fodp");
deck.save_as(&mut worker, &crate::file_url(&again), "fodp")
.expect("saves");
let xml = std::fs::read_to_string(&again).expect("reads");
let tag = xml.split(">Three<").next().expect("text");
let style = tag.rsplit("text:style-name=\"").next().expect("a style");
let style = style.split('"').next().expect("its name");
let def = xml
.split(&format!("<style:style style:name=\"{style}\""))
.nth(1)
.expect("its definition");
let def = def.split("</style:style>").next().expect("ends");
assert!(def.contains(r#"fo:text-align="end""#), "{def}");
// One paragraph aligned, one not: plain text would lose it.
let one = styled(
r#"<text:p text:style-name="End">One</text:p>"#,
"<text:p>Two</text:p>",
);
let path = dir.path().join("one.fodp");
std::fs::write(&path, one).expect("writes");
let deck = worker.load(&crate::file_url(&path)).expect("loads");
let read = show::notes(&worker.values(&deck, show::NOTES).expect("reads")).expect("parses");
assert!(read.rich, "the look varies");
worker
.run(&deck, "notes.set", &["Three", "0"])
.expect_err("refuses");
}
+192
View File
@@ -0,0 +1,192 @@
use super::input::{open, saved};
use super::*;
use crate::review;
const TEXT: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
<office:document xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" office:version="1.3" office:mimetype="application/vnd.oasis.opendocument.text">
<office:body><office:text><text:p>The cat saw a Cat and a dog. Teh cat ran wiht cats.</text:p></office:text></office:body>
</office:document>"#;
const WAIT: std::time::Duration = std::time::Duration::from_secs(5);
/// Runs a search and returns its matches.
fn search(worker: &mut Worker, doc: &Doc, seen: &mut Events, id: &str, values: &[&str]) {
let before = seen.searched;
worker
.run(doc, id, values)
.unwrap_or_else(|error| panic!("{id}: {error}"));
*seen = poll_until(worker, doc, seen, WAIT, |seen| seen.searched > before);
assert!(seen.searched > before, "{id} reports an outcome");
}
/// Right-clicks at a point and returns the kernel's menu.
fn menu(worker: &mut Worker, doc: &Doc, seen: &mut Events, (x, y): (i32, i32)) -> Option<String> {
click(worker, doc, x, y);
for kind in [0, 1] {
let event = Mouse {
kind,
x,
y,
count: 1,
buttons: 4,
modifiers: 0,
};
worker.post_mouse(doc, event).expect("the click posts");
}
*seen = poll_until(worker, doc, seen, WAIT, |seen| seen.menu.is_some());
seen.menu.take()
}
#[test]
fn find_counts_steps_and_replaces() {
if install().is_none() {
eprintln!("SKIP: no pinned LOK build");
return;
}
let (dir, mut worker, doc) = open(TEXT);
click(&mut worker, &doc, 150, 150);
let mut seen = Events::default();
let find = |all: &'static str, case: &'static str, word: &'static str| {
["cat", "false", all, case, word, "1"]
};
search(
&mut worker,
&doc,
&mut seen,
"find.run",
&find("1", "256", "0"),
);
assert_eq!(seen.found.len(), 4, "any case, inside words too");
search(
&mut worker,
&doc,
&mut seen,
"find.run",
&find("1", "0", "0"),
);
assert_eq!(seen.found.len(), 3, "match case");
search(
&mut worker,
&doc,
&mut seen,
"find.run",
&find("1", "256", "16"),
);
assert_eq!(seen.found.len(), 3, "whole words");
let all = seen.found.clone();
search(
&mut worker,
&doc,
&mut seen,
"find.run",
&find("0", "256", "16"),
);
assert_eq!(seen.found, all[..1], "next selects the first");
search(
&mut worker,
&doc,
&mut seen,
"find.run",
&["zebra", "false", "0", "256", "0", "1"],
);
assert!(
seen.found.is_empty(),
"no match is an outcome, not an error"
);
let replace = ["Cat", "false", "3", "0", "16", "1", "Lion"];
worker
.run(&doc, "find.replace", &replace)
.expect("replace all");
let counts = review::counts(&worker.values(&doc, ".uno:WordCount").expect("counts"))
.expect("counts parse");
assert_eq!((counts.words, counts.selected_words), (13, 0));
let text = saved(&dir, &mut worker, &doc, "found.fodt");
assert!(
text.contains("The cat saw a Lion and a dog."),
"only the cased whole word goes"
);
}
#[test]
fn spelling_suggests_and_ignores() {
if install().is_none() {
eprintln!("SKIP: no pinned LOK build");
return;
}
let (dir, mut worker, doc) = open(TEXT);
click(&mut worker, &doc, 150, 150);
let mut seen = Events::default();
search(
&mut worker,
&doc,
&mut seen,
"find.run",
&["Teh", "false", "0", "0", "0", "1"],
);
let (x, y, _, h) = seen.found[0];
let json = menu(&mut worker, &doc, &mut seen, (x + 60, y + h / 2)).expect("a menu");
let spelling = review::spelling(&json)
.expect("the menu parses")
.expect("a spelling menu");
let (_, rule) = spelling
.suggestions
.iter()
.find(|(label, _)| label == "The")
.expect("The is offered");
worker
.run(&doc, "spell.apply", &[rule])
.expect("the suggestion applies");
search(
&mut worker,
&doc,
&mut seen,
"find.run",
&["wiht", "false", "0", "0", "0", "1"],
);
let (x, y, _, h) = seen.found[0];
let json = menu(&mut worker, &doc, &mut seen, (x + 60, y + h / 2)).expect("a menu");
assert!(review::spelling(&json).expect("parses").is_some());
worker
.run(&doc, "spell.ignore_all", &["Spelling"])
.expect("ignore all");
let json = menu(&mut worker, &doc, &mut seen, (x + 60, y + h / 2)).expect("a menu");
assert_eq!(
review::spelling(&json).expect("parses"),
None,
"an ignored word is no longer wrong"
);
let text = saved(&dir, &mut worker, &doc, "spelled.fodt");
assert!(text.contains("The cat ran wiht"), "the suggestion lands");
}
#[test]
fn compare_marks_changes_against_an_older_file() {
if install().is_none() {
eprintln!("SKIP: no pinned LOK build");
return;
}
let (dir, mut worker, doc) = open(TEXT);
let older = dir.path().join("older.fodt");
let before = TEXT.replace("a dog", "a bird");
std::fs::write(&older, &before).expect("the older file writes");
worker
.run(&doc, "doc.compare", &[&crate::file_url(&older), "true"])
.expect("the compare runs");
let changes = review::changes(
&worker
.values(&doc, ".uno:AcceptTrackedChanges")
.expect("changes"),
)
.expect("changes parse");
assert!(
changes
.iter()
.any(|change| change.description.contains("dog")),
"the newer words show as changes: {changes:?}"
);
assert_eq!(
std::fs::read_to_string(&older).expect("reads"),
before,
"the older file stays"
);
}
+258
View File
@@ -0,0 +1,258 @@
use super::sheet::{SHEETS, open_book};
use super::*;
use crate::sheet;
/// How long a command may take to report.
const SETTLE: std::time::Duration = std::time::Duration::from_secs(5);
fn names(worker: &mut Worker, doc: &Doc) -> Vec<(String, bool)> {
doc.sheets(worker)
.expect("the sheets")
.list
.into_iter()
.map(|sheet| (sheet.name, sheet.visible))
.collect()
}
/// Runs `id` as an app does, at a fresh read.
fn run(worker: &mut Worker, doc: &Doc, id: &str, values: &[&str]) {
let at = worker.now(doc).expect("the state");
worker
.run(&at, id, values)
.unwrap_or_else(|error| panic!("{id} {values:?}: {error}"));
}
fn geometry(worker: &mut Worker, doc: &Doc) -> sheet::Geometry {
sheet::geometry(&worker.values(doc, sheet::GEOMETRY).expect("the geometry"))
.expect("the geometry parses")
}
#[test]
fn sheets_insert_rename_move_hide_and_delete() {
if install().is_none() {
eprintln!("SKIP: no pinned LOK build");
return;
}
let (_dir, mut worker, doc) = open_book(SHEETS);
let owned = |list: &[(&str, bool)]| -> Vec<(String, bool)> {
list.iter()
.map(|&(name, shown)| (name.to_string(), shown))
.collect()
};
let refs = sheet::references(
&worker
.values(&doc, &sheet::references_query(0))
.expect("the references"),
)
.expect("they parse");
assert_eq!((refs.formulas, refs.names), (1, 0), "U refers to S");
assert_eq!(refs.dynamic, 1, "INDIRECT on U is counted apart");
assert!(
refs.first.contains("U"),
"the first is on U: {}",
refs.first
);
run(&mut worker, &doc, "sheet.insert", &["New", "2"]);
run(&mut worker, &doc, "sheet.rename", &["Data", "2"]);
assert_eq!(
names(&mut worker, &doc),
owned(&[("S", true), ("Data", true), ("T", false), ("U", true)])
);
doc.show_sheet(&mut worker, 1).expect("Data shows");
run(&mut worker, &doc, "sheet.move", &["5", "false", "true"]);
run(&mut worker, &doc, "sheet.move", &["5", "true", "true"]);
let list = names(&mut worker, &doc);
assert_eq!(list[..3], owned(&[("S", true), ("T", false), ("U", true)]));
assert_eq!(list[3].0, "Data");
assert!(list[4].0.starts_with("Data"), "a copy: {}", list[4].0);
doc.show_sheet(&mut worker, 0).expect("S shows");
run(&mut worker, &doc, "sheet.move", &["2", "false", "true"]);
let order = |worker: &mut Worker| -> Vec<String> {
names(worker, &doc)
.into_iter()
.map(|(name, _)| name)
.collect()
};
assert_eq!(
order(&mut worker)[..3],
["T", "S", "U"],
"Index i+2 moves right"
);
run(&mut worker, &doc, "sheet.move", &["1", "false", "true"]);
assert_eq!(
order(&mut worker)[..3],
["S", "T", "U"],
"Index i moves left"
);
run(&mut worker, &doc, "sheet.hide", &["2"]);
run(&mut worker, &doc, "sheet.show", &["T"]);
let list = names(&mut worker, &doc);
assert_eq!(list[1], ("T".to_string(), true), "T shows");
assert_eq!(list[2], ("U".to_string(), false), "U hides");
run(&mut worker, &doc, "sheet.delete", &["1"]);
assert_eq!(names(&mut worker, &doc)[0].0, "T", "S is gone");
}
/// A 1, 2 column to fill, and cells to merge.
const GRID: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
<office:document xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0" office:version="1.3" office:mimetype="application/vnd.oasis.opendocument.spreadsheet">
<office:body><office:spreadsheet><table:table table:name="G">
<table:table-row><table:table-cell office:value-type="float" office:value="1"><text:p>1</text:p></table:table-cell><table:table-cell><text:p>b</text:p></table:table-cell></table:table-row>
<table:table-row><table:table-cell office:value-type="float" office:value="2"><text:p>2</text:p></table:table-cell></table:table-row>
</table:table></office:spreadsheet></office:body>
</office:document>"#;
#[test]
fn ranges_fill_size_hide_merge_and_take_formulas() {
if install().is_none() {
eprintln!("SKIP: no pinned LOK build");
return;
}
let (dir, mut worker, doc) = open_book(GRID);
run(&mut worker, &doc, "cell.goto", &["A1:A2"]);
run(&mut worker, &doc, "fill.auto", &["A5"]);
run(&mut worker, &doc, "cell.goto", &["C1"]);
run(&mut worker, &doc, "cell.enter", &["=SUM(A1:A5)"]);
run(&mut worker, &doc, "cell.goto", &["C1"]);
let summed = poll_until(&mut worker, &doc, &Events::default(), SETTLE, |seen| {
seen.cell.address == "C1" && seen.cell.formula == "=SUM(A1:A5)"
});
assert_eq!(summed.cell.formula, "=SUM(A1:A5)");
run(&mut worker, &doc, "cell.goto", &["F1:H1"]);
run(&mut worker, &doc, "columns.width_selected", &["3000"]);
run(&mut worker, &doc, "columns.width", &["2540", "4"]);
run(&mut worker, &doc, "cell.goto", &["E1"]);
run(&mut worker, &doc, "columns.hide", &[]);
let sizes = geometry(&mut worker, &doc);
assert_eq!(sizes.columns.size(3), 1440, "D is 1 inch wide");
assert_eq!(sizes.columns.size(4), 0, "E hides");
assert_eq!(
(5..8)
.map(|column| sizes.columns.size(column))
.collect::<Vec<_>>(),
[1701, 1701, 1701],
"F to H all take 3 cm"
);
run(&mut worker, &doc, "select.column", &["1", "0"]);
run(&mut worker, &doc, "select.row", &["2", "4096"]);
run(&mut worker, &doc, "cell.goto", &["B2"]);
run(&mut worker, &doc, "view.freeze", &[]);
let frozen = poll_until(&mut worker, &doc, &Events::default(), SETTLE, |seen| {
let state = |uno: &str| {
seen.states
.iter()
.rev()
.find(|(name, _)| name == uno)
.map(|(_, state)| state.clone())
};
state(".uno:FreezePanesColumn").as_deref() == Some("1 / 0")
&& state(".uno:FreezePanesRow").as_deref() == Some("1 / 0")
});
assert!(
frozen
.states
.iter()
.any(|(name, state)| name == ".uno:FreezePanesRow" && state == "1 / 0"),
"B2 freezes one row and one column: {:?}",
frozen.states
);
run(&mut worker, &doc, "cell.goto", &["A1"]);
run(&mut worker, &doc, "rows.insert_above", &[]);
run(&mut worker, &doc, "cell.goto", &["A2:B2"]);
assert_eq!(
doc.selection_text(&mut worker)
.expect("the text")
.trim_end(),
"1\tb",
"two cells hold data"
);
run(&mut worker, &doc, "cells.merge", &["true"]);
let out = dir.path().join("out.fods");
doc.save_as(&mut worker, &crate::file_url(&out), "fods")
.expect("the save");
let xml = std::fs::read_to_string(&out).expect("the save reads");
assert!(
xml.contains("table:number-columns-spanned=\"2\""),
"A2:B2 merges"
);
assert!(
xml.contains("of:=SUM([.A2:.A6])"),
"the formula follows the inserted row"
);
assert!(
xml.contains("office:value=\"14\""),
"the fill reaches 5; the merged text drops out of the sum"
);
assert!(xml.contains("<text:p>1 b</text:p>"), "the contents move");
}
#[test]
fn two_workbooks_keep_their_own_sheets() {
if install().is_none() {
eprintln!("SKIP: no pinned LOK build");
return;
}
let (dir, mut worker, first) = open_book(SHEETS);
first.show_sheet(&mut worker, 2).expect("U shows");
let grid = dir.path().join("grid.fods");
std::fs::write(&grid, GRID).expect("the grid writes");
let second = worker
.load(&crate::file_url(&grid))
.expect("the second book loads");
first.show_sheet(&mut worker, 0).expect("S shows");
assert_eq!(first.sheets(&mut worker).expect("sheets").current, 0);
assert_eq!(second.sheets(&mut worker).expect("sheets").current, 0);
run(&mut worker, &first, "cell.goto", &["C1"]);
run(&mut worker, &first, "cell.enter", &["123456"]);
let out = dir.path().join("first.fods");
first
.save_as(&mut worker, &crate::file_url(&out), "fods")
.expect("the save");
let xml = std::fs::read_to_string(&out).expect("the save reads");
let on_s = &xml[..xml.find("table:name=\"T\"").expect("sheet T")];
assert!(
on_s.contains("office:value=\"123456\""),
"the entry lands on S"
);
}
#[test]
fn a_sheet_by_number_needs_the_state_it_was_read_in() {
if install().is_none() {
eprintln!("SKIP: no pinned LOK build");
return;
}
let (_dir, mut worker, doc) = open_book(SHEETS);
let read = worker.now(&doc).expect("the state");
worker.run(&read, "sheet.delete", &["1"]).expect("deletes S");
// Sheet 2 was T when read; it is U now.
let stale = worker.run(&read, "sheet.rename", &["Renamed", "2"]).expect_err("an older state");
assert!(crate::is_stale(&stale), "{stale}");
let only = |worker: &mut Worker| names(worker, &doc).into_iter().map(|(name, _)| name).collect::<Vec<_>>();
assert_eq!(only(&mut worker), ["T", "U"], "nothing was renamed");
run(&mut worker, &doc, "sheet.rename", &["Renamed", "2"]);
assert_eq!(only(&mut worker), ["T", "Renamed"]);
// A loaded handle never names a sheet after edits.
assert!(worker.run(&doc, "sheet.delete", &["1"]).is_err());
}
#[test]
fn commands_from_one_read_run_together_and_hovering_changes_nothing() {
if install().is_none() {
eprintln!("SKIP: no pinned LOK build");
return;
}
let (_dir, mut worker, doc) = open_book(SHEETS);
let read = worker.now(&doc).expect("the state");
let hover = crate::Mouse { kind: 2, x: 500, y: 500, count: 0, buttons: 0, modifiers: 0 };
worker.post_mouse(&read, hover).expect("hovers");
assert_eq!(worker.now(&doc).expect("the state"), read, "a hover is no change");
// Both numbers come from one read.
let renames: [(&str, &[&str]); 2] = [("sheet.rename", &["One", "1"]), ("sheet.rename", &["Two", "2"])];
worker.run_all(&read, &renames).expect("renames both");
let only = |worker: &mut Worker| names(worker, &doc).into_iter().map(|(name, _)| name).collect::<Vec<_>>();
assert_eq!(only(&mut worker), ["One", "Two", "U"]);
let stale = worker.run(&read, "sheet.rename", &["Three", "3"]).expect_err("an older state");
assert!(crate::is_stale(&stale), "{stale}");
assert_eq!(only(&mut worker), ["One", "Two", "U"], "nothing else was renamed");
}